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.
This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-05-28 18:17:49 +07:00
1 parent bc3bc25c98
commit a591473546
55 files changed
+5659 -123

No files matched your search

+3
View File
@@ -48,6 +48,9 @@ func (s *stubSecureCLIStoreCmd) GetUserCredentials(ctx context.Context, binaryID
func (s *stubSecureCLIStoreCmd) SetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte) error {
return nil
}
func (s *stubSecureCLIStoreCmd) SetUserCredentialsTyped(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte, credentialType, hostScope *string) error {
return nil
}
func (s *stubSecureCLIStoreCmd) DeleteUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) error {
return nil
}
+29
View File
@@ -397,6 +397,35 @@ Tool output is automatically scrubbed before being returned to the LLM or the us
The exact patterns are intentionally not published here (defense-in-depth). The scrubber is always enabled in the registry by default.
### 8a. Credential adapter framework
For tool binaries that need per-user typed credentials (PAT, SSH key,
kubeconfig, `.pgpass`, etc.), the `CredentialAdapter` interface in
`internal/tools/credential_adapter.go` transforms a stored credential into
the argv/env/ephemeral-file shape the binary expects.
- **`Name() string`** — the value stored in `secure_cli_binaries.adapter_name`.
- **`ShouldInject(argv []string) bool`** — gate that skips local-only
subcommands (e.g. `git status` does not trigger injection).
- **`Prepare(...) (*Injection, error)`** — returns the four-field
`Injection{ArgvPrefix, Env, Cleanup, ScrubValues}` consumed by
`credentialed_exec.go`.
Adapters are registered in their own `init()` via `RegisterAdapter`. Lookup
falls back to the `passthrough` no-op adapter on unknown/empty names, so
unrelated presets (`gh`, `aws`, `gcloud`, `kubectl`, `terraform`, `gws`)
keep their legacy env-injection path bit-for-bit.
Per-injection audit: every adapter run emits one
`slog.Warn("security.system_env_injection", …)` line with the field schema
documented in [09-security.md § 14](./09-security.md#14-cli-credential-adapters).
To author a new adapter (kubectl, docker, npm, aws, …), follow the worked
mappings + interface-validation gate in
[credential-adapter-playbook.md](./credential-adapter-playbook.md). User-facing
config for the shipped `git` adapter is documented in
[git-credential-adapter.md](./git-credential-adapter.md).
---
## 9. Per-Tenant Config (4-Tier Overlay)
+105
View File
@@ -497,6 +497,111 @@ Package name validation is defense-in-depth at three layers:
---
## 14. CLI Credential Adapters
The CLI credential adapter framework is the system-trusted path for injecting
auth material into spawned CLI subprocesses (`git clone`, `kubectl apply`,
`psql`, …). It is the second of two distinct trust boundaries for env-var
injection — distinct from, not a replacement for, the user-paste denylist.
User guide: [git-credential-adapter.md](./git-credential-adapter.md).
Implementer guide: [credential-adapter-playbook.md](./credential-adapter-playbook.md).
### Trust-boundary diagram
```
┌──────────────────────────────┐ ┌──────────────────────────────────┐
│ User-pasted env vars │ │ System-injected adapter Env │
│ (CLI Credentials → "env") │ │ (CredentialAdapter.Prepare) │
│ │ │ │
│ ValidateGrantEnvVars │ │ bypasses denylist │
│ rejects GIT_SSH_COMMAND, │ │ emits security.system_env_ │
│ LD_PRELOAD, PATH, … │ │ injection slog.Warn per call │
└──────────────────────────────┘ └──────────────────────────────────┘
first line of defense second, audit-trailed line
```
Both paths coexist. A typo in `adapter_name` falls back to passthrough, which
restores the legacy denylist-only behavior — no silent bypass.
### Audit log: `security.system_env_injection`
Every adapter injection emits **exactly one** structured slog line. Field
schema is pinned by `TestEmitSystemEnvInjectionAudit_*` in
`internal/tools/credential_audit_log_test.go` — changes here must update both
the test and operator-facing log-search recipes.
| Field | Type | Notes |
| ----- | ---- | ----- |
| `msg` | string | always `security.system_env_injection` |
| `adapter` | string | e.g. `git`, `psql`, `passthrough` |
| `binary` | string | binary name (`git`, `kubectl`, …) |
| `user_id` | string | tenant user UUID (empty for global-only contexts) |
| `env_keys` | []string | sorted env-var NAMES (never values) |
| `argv_prefix_len` | int | number of argv elements prepended (NOT their content) |
| `host_scope_hash` | string | SHA-256 first 8 hex chars of normalized host_scope, or `"none"` |
**Plaintext hostname is intentionally omitted** to keep audit logs PII-safe
when goclaw is deployed inside a regulated tenant. Operators wanting to grep
for activity against a specific host pre-compute the hash:
```sh
echo -n "github.com" | sha256sum | cut -c1-8
```
Routing: `slog.Warn` writes to whatever the host runtime captures — for the
default goclaw deployment that's stderr → systemd/journald or Docker logs.
There is **no dedicated audit table** in v1 (see future work below).
### SSH TOFU MITM caveat
The git adapter's SSH path sets `StrictHostKeyChecking=accept-new`, which
accepts unknown host keys on first contact. 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` at deployment time:
```sh
ssh-keyscan github.com >> ~/.ssh/known_hosts
ssh-keyscan -p 22 gitea.internal >> ~/.ssh/known_hosts
```
Once a host key is in `known_hosts`, `accept-new` enforces match-or-fail on
subsequent connections — the TOFU window is one connection per host.
v2 will support per-credential pinned host keys (removes TOFU entirely).
### SIGKILL residual material
Ephemeral filesystem credentials (SSH key tmpfiles, `.pgpass` tmpfiles, future
KUBECONFIG/DOCKER_CONFIG tmpfiles) rely on `defer cleanup()` to remove
themselves after exec returns.
`SIGKILL` of the goclaw process leaves these 0600 files in `os.TempDir()`. On
POSIX, `os.TempDir()` is per-user, so exposure is limited to the goclaw uid.
High-security deployments should run a periodic sweep:
```sh
find "$TMPDIR" -name 'goclaw-gitkey-*' -mmin +60 -delete
find "$TMPDIR" -name 'goclaw-pgpass-*' -mmin +60 -delete
```
### Open future work
- Dedicated `audit_log` table for `security.system_env_injection` events
(operator-grade query surface; v1 only writes slog).
- Multi-credential per user with host-routing logic (v1: one per
user+binary+host_scope).
- Sandbox/Docker exec path support (v1 adapter is incompatible with the
bind-mount-based sandbox path).
- Pinned SSH host keys per credential (replace TOFU).
- Credential-refresh primitive for `aws sts assume-role`-style short-lived
STS credentials.
---
## File Reference
| Module | Path | Purpose |
+343
View File
@@ -0,0 +1,343 @@
# 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](./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`](../internal/tools/credential_adapter.go).
```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 in `secure_cli_binaries.adapter_name`.
It is also the value logged in `security.system_env_injection.adapter`.
- `ShouldInject` decides whether the adapter runs for THIS invocation. For
tools without subcommands (`psql`, `docker push`, etc.) return `true`. For
tools where some subcommands are local-only (`git status`, `kubectl version`)
return `false` to skip injection and audit-noise.
- `Prepare` produces a single `*Injection` per 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()`:
```go
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
```go
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`](../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`](../internal/tools/credential_ephemeral.go).
```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:
- `0600` perms, per-user `os.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](./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
```go
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 pods` 401 stderr.
### docker
```go
// 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_CONFIG` is a directory, not a file. Requires a small
`materializeEphemeralDir` sibling 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 `auth` value AND the decoded `user:pass` form,
because docker error output can decode and echo either.
### npm
```go
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).
Skip `npm list`, `npm version`, `npm run …`.
### aws
```go
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-role` returns 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`](../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; `ShouldInject` returns `true`
always.
- **Edge case handled**: `escapePgpass()` escapes backslash and colon per the
libpq `.pgpass` spec 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:
1. **Does `Prepare` fit 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.
2. **Can the secret reach the subprocess without ever touching `ArgvPrefix`?**
If no, document why and accept the `/proc/<pid>/cmdline` exposure
explicitly in the security section of your phase file.
3. **Does the tool's error path emit the secret in a form `ScrubValues`
wouldn't catch?** (e.g. base64-wrapped, URL-encoded, partially echoed.)
Enumerate the wrappings and add each to `ScrubValues`.
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 in `Prepare` or extend the
`Injection` shape (and update every other adapter).
- **Do NOT introduce a parallel `materializeFoo` helper.** Use the shared
`materializeEphemeral` (or extend it). One helper means one place to audit
the perms/cleanup contract.
- **Do NOT log plaintext `host_scope` to audit.** Use `hashHostScope()` —
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 `ShouldInject` gating** 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{}, nil` and let the subprocess fail with its own clear auth
error. Hard error here breaks the un-credentialed fallback path.
+206
View File
@@ -0,0 +1,206 @@
# 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`:
```sh
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:
```sh
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:
```sh
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.
@@ -0,0 +1,83 @@
# CLI Credential Adapters — git PAT + SSH, Framework Complete
**Date**: 2026-05-28 17:29
**Severity**: Medium
**Component**: CLI credential injection, git adapter, audit logging
**Status**: Resolved
## What Happened
Issue #82 (CLI Credential Adapters) completed across 6 commits spanning phases 1–6. The core problem: legacy CLI credential flow asked users to paste arbitrary env vars (e.g., `GIT_TOKEN`), but `git` does not read auth from any stable env var, so every clone failed silently. The solution: generic `CredentialAdapter` interface with typed `git` adapter supporting PAT (via GIT_CONFIG_COUNT env, never argv) and SSH (0600 tmpfile + GIT_SSH_COMMAND).
## The Brutal Truth
We shipped with the understanding that we had **no production git-auth flow at all**. Users copy-pasted a token into the wrong place and watched clones fail with "access denied" because goclaw was silently running `git clone` unauthenticated. The framework we built is the *first* correct solution; it's not a refactor of something that worked.
The frustrating part: we validated this by catching the auth failure *during testing* — the legacy passthrough adapter with no host scope is a safety valve, not a feature. If a user later tries to use it anyway, they get the exact same silent-failure behavior they had before, but at least now they have a path to fix it.
## Technical Details
### PAT Path (Phase 3, commit b0dccbe3)
- **Mechanism**: GIT_CONFIG_COUNT + GIT_CONFIG_KEY_0 + GIT_CONFIG_VALUE_0 environment variables (git 2.31+). Token never touches argv, .git/config, or remote URL.
- **Host-scope enforcement**: IDN normalization (golang.org/x/net/idna), 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.
- **DenyArgs blocking**: case-insensitive rejection of `-c http.`, `-c credential.`, `-c core.sshcommand`, `config --global/--system`, `credential-helper`, bare `daemon`.
- **Tests**: 14 unit tests (subcommand routing, host normalization, scp-form parsing, userinfo rejection, CRLF token rejection, CVE-2018-17456 regression, DenyArgs coverage) + 3 integration tests against TLS git-http-backend proving zero token leakage into cloned .git/config.
### SSH Path (Phase 4, commit b0dccbe3)
- **Mechanism**: Per-call 0600 tmpfile materialized via Phase 2b `materializeEphemeral()` helper. GIT_SSH_COMMAND injected with `-o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new`. Idempotent cleanup via defer.
- **Validation**: golang.org/x/crypto/ssh parses key blob. Passphrase-protected keys rejected with `ErrSSHKeyPassphraseUnsupported` sentinel.
- **SSH TOFU trade-off**: `accept-new` accepted with documented ssh-keyscan mitigation; pinned host keys deferred to v2.
- **Tests**: 8 unit tests (passphrase rejection, env shape, cleanup lifecycle, host-mismatch reuse, malformed-blob rejection) + 3 integration tests proving tmpfile 0600 lifecycle, env propagation to child, cleanup-on-exec-failure, no-orphan-on-rejection.
### Audit Logging (Phase 6, commit 16a0f303)
- **Schema**: `emitSystemEnvInjectionAudit()` centralizes slog `security.system_env_injection` with host_scope_hash (SHA-256 first 8 hex, plaintext hostname omitted for PII safety). Operators pre-compute hash to grep audit streams.
- **Leak detection testing**: `TestEmitSystemEnvInjectionAudit_PAT` and `TestEmitSystemEnvInjectionAudit_SSH` assert that: (a) env NAMES go into audit, env VALUES do NOT; (b) PAT/PEM bytes never appear in log buffer.
### Extensibility Framework (Phase 2, commit 1fe7c5e0 + Phase 2b, commit 14cce5b9)
- **Interface**: `CredentialAdapter` with `ValidateCredential()` + `Inject()`. Passthrough default + git typed + psql stub (Phase 2b, proof of generalization).
- **WithExecCwd / ExecCwdFromContext helpers** (Phase 3): fixes latent design gap where adapter's pre-flight `git config --get` ran in goclaw's daemon CWD, not the agent's repo CWD.
## What We Tried
1. **memfd vs. tmpfile for SSH key**: Initial design used `/proc/self/fd/N` for memory-backed file. Validation revealed `/proc/self/fd/N` resolves "self" against the **caller** (goclaw), not the child process (git→ssh grandchild). git doesn't expose a mechanism to inherit fds without explicit cooperation. Reverted to 0600 tmpfile + defer cleanup.
2. **Sentinel values in leak-detection tests**: First attempt used short sentinels like "1" for `GIT_CONFIG_COUNT`. False-positive matches occurred in slog timestamp digits (e.g., `"timestamp":"2026-05-28T17:29:21.000123..."`). Fixed by using distinctly-formed sentinels: `SENTINEL_COUNT_VALUE`, `ghp_SENTINEL_PAT_VALUE_4242424242424242` (long enough that random substring collision is negligible).
3. **Passphrase-protected SSH keys**: Attempted to detect and prompt for passphrase during validation. Decision: reject them entirely. Rationale: no UX slot to store or retrieve the passphrase on each exec; kubernetes-style service-account flow (unencrypted keys) is the baseline, and v2 can add passphrase support if required.
## Root Cause Analysis
The original mistake was designing a credential injection system without typing. The `CredentialAdapter` interface was necessary because different tools have **entirely different** auth mechanisms (git: env vars + config, kubectl: kubeconfig file, npm: .npmrc auth field, aws: STS assume-role), and a single "paste env var" flow cannot adapt to all of them.
The silent-failure symptom persisted through phases 1–2 because the framework was incomplete: once typed, the git adapter needed explicit host-scope routing + HTTPS path (GIT_CONFIG_COUNT), SSH path (tmpfile), and audit logging. Each missing piece meant git would silently fall back to unauthenticated mode.
## Lessons Learned
1. **Sentinel values for "secret not in log" assertions must be distinctive enough that random text cannot match.** Short sentinels (1–2 chars) collide with digits in timestamps; use longer, structured sentinels (`SENTINEL_*` prefix, base16 suffix) so substring matches are meaningful.
2. **env-var-based auth has hard scaling limits.** git's flexibility (multiple auth backends) means no single env var controls it all. Typed adapters per tool are not optional.
3. **fd inheritance across process boundaries is tool-specific.** `/proc/self/fd/N` is not a portable secret-passing mechanism; tmpfile + 0600 + defer is simpler and more predictable, even if it hits the filesystem.
4. **Host-scope is the invariant.** v1 enforces (user, adapter, host_scope) → one credential. Wildcards and multi-host credential reuse are deferred because they require explicit escrow-side design (key rotation, credential audit hooks).
5. **Context propagation for exec environment must trace the agent's repo CWD, not the daemon's CWD.** WithExecCwd helpers prevent subtle auth failures when the adapter runs pre-flight checks in the wrong directory.
## Next Steps
**Shipped in commits b0dccbe3 → 16a0f303:**
- git adapter (PAT + SSH paths), psql stub (proof of extensibility), audit logging schema, 2 user guides (git-credential-adapter.md, credential-adapter-playbook.md).
- 4 new docs: 09-security.md (trust boundary + SSH TOFU caveats), 03-tools-system.md (audit shape integration).
- 17 i18n keys × 3 locales (en/vi/zh) + React credential dialog rework.
- 41 tests (unit + integration, PG + SQLite both exercised).
**Blocked / Deferred to v2:**
- **Sandbox incompatibility**: adapter cannot grant filesystem perms to bind-mount creds. Design needed for sandboxed exec contexts.
- **Credential-refresh primitive**: no mechanism for `aws sts assume-role` (multi-step auth). Requires OAuth-callback plumbing.
- **Dedicated audit_log table**: currently relies on slog→stderr→journald. Production operators prefer queryable DB table.
- **kubectl, docker, npm, aws, psql production adapters**: framework + playbook ready (credential-adapter-playbook.md), implementation staged for Q3 2026.
**Watch for:**
- Operators who copy legacy env-var workflows into the new system and expect them to "just work" — audit logs + docs should catch these, but social effort needed.
- SSH host-key TOFU attacks in zero-trust networks — ssh-keyscan baseline + pinned-host-keys (v2) are the mitigations.
+77
View File
@@ -4,6 +4,83 @@ Significant changes, features, and fixes in reverse chronological order.
---
## 2026-05-28
### CLI credential adapter framework + git adapter (issue #82)
Refactors `credentialed_exec.go` from "flat env-var injection only" to a
generic `CredentialAdapter` interface that supports argv prefix injection,
ephemeral filesystem material, system-trusted env vars, and per-injection
redaction. Existing presets (`gh`, `aws`, `gcloud`, `kubectl`, `terraform`,
`gws`) route through a passthrough adapter and keep current behavior
bit-for-bit.
**New**
- `CredentialAdapter` interface + registry in `internal/tools/credential_adapter.go`.
Default `passthrough` adapter preserves legacy behavior; named adapters
register via `init() → RegisterAdapter`.
- `git` adapter (`internal/tools/credential_adapter_git.go`) covering both
HTTPS PAT and SSH key paths. PAT injected via `GIT_CONFIG_COUNT` +
`GIT_CONFIG_KEY_*`/`GIT_CONFIG_VALUE_*` env vars (never argv → keeps token
off `ps`/`/proc/<pid>/cmdline`). SSH key materialized to 0600 tmpfile +
`GIT_SSH_COMMAND` with `IdentitiesOnly=yes` and `StrictHostKeyChecking=accept-new`.
Passphrase-protected SSH keys rejected at validation with
`git.cred_ssh_passphrase_unsupported`.
- `psql` framework-validation stub adapter (`internal/tools/credential_adapter_psql.go`)
proving the interface holds for non-git credential families.
- Shared `materializeEphemeral` helper (`internal/tools/credential_ephemeral.go`)
with idempotent cleanup latch and explicit `0600` chmod.
- Per-injection audit log: `slog.Warn("security.system_env_injection", …)`
with `adapter`, `binary`, `user_id`, sorted `env_keys` (names only),
`argv_prefix_len`, `host_scope_hash` (SHA-256 first 8 hex chars — plaintext
hostname intentionally omitted for PII safety). Schema pinned by
`TestEmitSystemEnvInjectionAudit_*`.
- Typed-credential HTTP PUT path with `{error:{code,message}, error_key}`
envelope so the web UI can drive field-level validation
(`git.cred_host_scope_required`, `git.cred_ssh_passphrase_unsupported`,
etc.).
- Web UI: `CliCredentialGitFields` extends the CLI Credentials dialog with
`Personal Access Token` / `SSH Private Key` picker, host-scope input,
CRLF→LF paste normalization, masked-secret edit flow (`••••••••`
placeholder preserves stored value on save).
- 17 i18n keys × 3 locales (en/vi/zh).
**Docs**
- New: `docs/git-credential-adapter.md` — user-facing guide (when to use PAT
vs SSH vs env, host-scope semantics, TOFU caveat with `ssh-keyscan`
mitigation, SIGKILL residual material note, operator sweep recipe).
- New: `docs/credential-adapter-playbook.md` — implementer guide with worked
mappings for `kubectl`, `docker`, `npm`, `aws`, `psql` and the three-question
interface-validation gate.
- `docs/09-security.md` § 14 — trust-boundary diagram, audit field schema,
SSH TOFU + SIGKILL caveats, v2 future-work list.
- `docs/03-tools-system.md` § 8a — adapter framework summary linking to the
playbook.
**Security**
- User-paste denylist (`ValidateGrantEnvVars`) unchanged — first line of
defense intact. Adapter path is the second, audit-trailed line; a typo in
`adapter_name` falls back to passthrough (no silent bypass).
- AES-256-GCM at rest for stored PAT/SSH bodies; secrets cannot be read back
via API/UI.
- `ScrubCredentials` redacts PAT bytes and SSH key path from stdout, stderr,
`Result.Content`, and audit log JSON.
**Known v1 limitations**
- One credential per (user, binary, host_scope).
- No multi-host wildcard (`*.github.com`).
- No persistent `known_hosts` per credential (TOFU only).
- No sandbox support (adapter is incompatible with bind-mount-based sandbox
path).
- No credential-refresh primitive (blocks future `aws sts assume-role`
adapter).
---
## 2026-05-27
### zuey VPS ops scripts: repo-tracked + CI auto-sync
@@ -48,6 +48,9 @@ func (s *fakeSecureCLIStore) GetUserCredentials(context.Context, uuid.UUID, stri
func (s *fakeSecureCLIStore) SetUserCredentials(context.Context, uuid.UUID, string, []byte) error {
return nil
}
func (s *fakeSecureCLIStore) SetUserCredentialsTyped(context.Context, uuid.UUID, string, []byte, *string, *string) error {
return nil
}
func (s *fakeSecureCLIStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error {
return nil
}
@@ -0,0 +1,223 @@
// Phase 5: typed credential write path for SecureCLIUserCredentials.
//
// The legacy PUT body `{env: {...}}` lives untouched in
// secure_cli_user_credentials.go; this file adds the new
// `{credential_type, host_scope, blob}` branches for typed adapters
// (pat / ssh_key). All validation runs BEFORE encryption + DB write so the
// store row never holds a malformed credential.
package http
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"golang.org/x/net/idna"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// hostScopeRe matches lowercase hostnames with optional port. Applied AFTER
// idna.ToASCII normalization so IDN punycode is treated as plain ASCII.
// Restrictive on purpose — prevents CRLF injection (\r\n) and weird Unicode
// from landing in the host_scope column and later being used in a `git config`
// key like http.https://<host>/.extraheader (Phase 3 wire shape).
var hostScopeRe = regexp.MustCompile(`^[a-z0-9.-]+(:[0-9]+)?$`)
// typedCredentialBody is the additive PUT shape introduced in Phase 5.
// All three new fields are nullable so the legacy `{env: {...}}` path keeps
// working unchanged.
type typedCredentialBody struct {
CredentialType *string `json:"credential_type,omitempty"`
HostScope *string `json:"host_scope,omitempty"`
Blob json.RawMessage `json:"blob,omitempty"`
}
// errTypedCredential carries both an HTTP status and the i18n-resolved
// message so handleSetUserCredentials can `writeJSON` once without leaking
// validation details through fmt.Sprintf.
type errTypedCredential struct {
status int
msg string
// errorKey is exposed to the frontend so dialogs can map specific failures
// (e.g. passphrase rejection) to inline UI feedback without parsing the
// localized human message.
errorKey string
}
func (e *errTypedCredential) Error() string { return e.msg }
// prepareTypedCredentialEnv validates and converts the new payload shape into
// the bytes that get encrypted-and-stored. Returns (envBytes, credentialType,
// hostScope, error). On success, envBytes is the JSON-encoded blob (e.g.
// `{"token":"..."}` or `{"key":"..."}`) ready for SetUserCredentialsTyped.
func prepareTypedCredentialEnv(locale string, body typedCredentialBody) ([]byte, *string, *string, *errTypedCredential) {
if body.CredentialType == nil || *body.CredentialType == "" || *body.CredentialType == "env" {
// Caller should route to legacy env path. Indicate that with all-nil
// returns + nil error; handler treats this as "fall through".
return nil, nil, nil, nil
}
credType := *body.CredentialType
if credType != "pat" && credType != "ssh_key" {
return nil, nil, nil, &errTypedCredential{
status: http.StatusBadRequest,
msg: i18n.T(locale, i18n.MsgGitCredUnsupportedCredType, credType),
errorKey: "git.cred_unsupported_type",
}
}
// Host scope is required + validated for both pat and ssh_key.
scope, terr := validateHostScope(locale, body.HostScope, credType)
if terr != nil {
return nil, nil, nil, terr
}
if len(body.Blob) == 0 {
return nil, nil, nil, &errTypedCredential{
status: http.StatusBadRequest,
msg: i18n.T(locale, i18n.MsgGitCredBlobMissingField, "blob"),
errorKey: "git.cred_blob_missing",
}
}
var blob map[string]string
if err := json.Unmarshal(body.Blob, &blob); err != nil {
return nil, nil, nil, &errTypedCredential{
status: http.StatusBadRequest,
msg: i18n.T(locale, i18n.MsgGrantEnvValueInvalid, err.Error()),
errorKey: "git.cred_blob_invalid",
}
}
switch credType {
case "pat":
token := strings.TrimSpace(blob["token"])
if token == "" {
return nil, nil, nil, &errTypedCredential{
status: http.StatusBadRequest,
msg: i18n.T(locale, i18n.MsgGitCredBlobMissingField, "token"),
errorKey: "git.cred_blob_missing_token",
}
}
if err := tools.ValidatePATToken(token); err != nil {
return nil, nil, nil, &errTypedCredential{
status: http.StatusBadRequest,
msg: i18n.T(locale, i18n.MsgGitCredTokenInvalid),
errorKey: "git.cred_token_invalid",
}
}
envBytes, _ := json.Marshal(map[string]string{"token": token})
ct := credType
return envBytes, &ct, &scope, nil
case "ssh_key":
// Windows clipboard often pastes CRLF; ssh.ParsePrivateKey expects LF
// and chokes silently otherwise. Normalize BEFORE validation so the
// stored bytes match what ssh -i will read at exec time.
key := strings.ReplaceAll(blob["key"], "\r\n", "\n")
if strings.TrimSpace(key) == "" {
return nil, nil, nil, &errTypedCredential{
status: http.StatusBadRequest,
msg: i18n.T(locale, i18n.MsgGitCredBlobMissingField, "key"),
errorKey: "git.cred_blob_missing_key",
}
}
if err := tools.ValidateSSHKey([]byte(key)); err != nil {
if errors.Is(err, tools.ErrSSHKeyPassphraseUnsupported) {
return nil, nil, nil, &errTypedCredential{
status: http.StatusBadRequest,
msg: i18n.T(locale, i18n.MsgGitCredSSHPassphraseUnsupported),
errorKey: "git.cred_ssh_passphrase_unsupported",
}
}
return nil, nil, nil, &errTypedCredential{
status: http.StatusBadRequest,
msg: i18n.T(locale, i18n.MsgGitCredSSHKeyInvalid, err.Error()),
errorKey: "git.cred_ssh_key_invalid",
}
}
envBytes, _ := json.Marshal(map[string]string{"key": key})
ct := credType
return envBytes, &ct, &scope, nil
}
// Unreachable — credType already gated above.
return nil, nil, nil, &errTypedCredential{
status: http.StatusInternalServerError,
msg: fmt.Sprintf("unreachable credential_type %q", credType),
}
}
// validateHostScope normalizes via idna.ToASCII (matches Phase 3's runtime
// comparison) then enforces hostScopeRe. Returns the canonical host_scope
// the row should store.
func validateHostScope(locale string, raw *string, credType string) (string, *errTypedCredential) {
if raw == nil || strings.TrimSpace(*raw) == "" {
return "", &errTypedCredential{
status: http.StatusBadRequest,
msg: i18n.T(locale, i18n.MsgGitCredHostScopeRequired, credType),
errorKey: "git.cred_host_scope_required",
}
}
scope := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(*raw), "."))
// Optional host:port split — only the host part goes through idna.
hostPart, port, hasPort := splitHostScopePort(scope)
ascii, err := idna.Lookup.ToASCII(hostPart)
if err != nil {
return "", &errTypedCredential{
status: http.StatusBadRequest,
msg: i18n.T(locale, i18n.MsgGitCredHostScopeInvalid, *raw),
errorKey: "git.cred_host_scope_invalid",
}
}
normalized := ascii
if hasPort {
normalized = ascii + ":" + port
}
if !hostScopeRe.MatchString(normalized) {
return "", &errTypedCredential{
status: http.StatusBadRequest,
msg: i18n.T(locale, i18n.MsgGitCredHostScopeInvalid, *raw),
errorKey: "git.cred_host_scope_invalid",
}
}
return normalized, nil
}
func splitHostScopePort(h string) (host, port string, ok bool) {
idx := strings.LastIndex(h, ":")
if idx < 0 {
return h, "", false
}
port = h[idx+1:]
if port == "" {
return h, "", false
}
for _, r := range port {
if r < '0' || r > '9' {
return h, "", false
}
}
return h[:idx], port, true
}
// writeTypedCredentialError serializes errTypedCredential into the standard
// gateway error envelope `{error:{code,message}}` so the shared HttpClient
// parser picks up `code` as `error_key`. We ALSO keep a flat `error_key` field
// at the top level for older clients and tests that parse the body directly.
func writeTypedCredentialError(w http.ResponseWriter, e *errTypedCredential) {
inner := map[string]string{"message": e.msg}
if e.errorKey != "" {
inner["code"] = e.errorKey
}
body := map[string]any{"error": inner}
if e.errorKey != "" {
body["error_key"] = e.errorKey
}
writeJSON(w, e.status, body)
}
@@ -0,0 +1,249 @@
// Phase 5 tests for the typed-credential PUT branches on
// handleSetUserCredentials. Backend equivalents of the frontend Vitest cases:
// PAT happy path, SSH passphrase rejection, missing host_scope, legacy env
// unchanged. Uses a recording fake store to assert exactly what landed.
package http
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// recordingSecureCLIStore captures the args of every Set*Credentials call so
// tests can assert exactly what bytes landed at the encryption boundary.
type recordingSecureCLIStore struct {
fakeSecureCLIStore
lastTypedEnv []byte
lastTypedType *string
lastTypedScope *string
lastLegacyEnv []byte
typedCalls int
legacyCalls int
existingForGet *store.SecureCLIUserCredential
}
func (s *recordingSecureCLIStore) SetUserCredentialsTyped(_ context.Context, _ uuid.UUID, _ string, encryptedEnv []byte, credentialType, hostScope *string) error {
s.lastTypedEnv = append([]byte(nil), encryptedEnv...)
s.lastTypedType = credentialType
s.lastTypedScope = hostScope
s.typedCalls++
return nil
}
func (s *recordingSecureCLIStore) SetUserCredentials(_ context.Context, _ uuid.UUID, _ string, encryptedEnv []byte) error {
s.lastLegacyEnv = append([]byte(nil), encryptedEnv...)
s.legacyCalls++
return nil
}
func (s *recordingSecureCLIStore) GetUserCredentials(context.Context, uuid.UUID, string) (*store.SecureCLIUserCredential, error) {
if s.existingForGet == nil {
return nil, nil
}
cp := *s.existingForGet
return &cp, nil
}
func putUserCred(t *testing.T, h *SecureCLIHandler, binaryID uuid.UUID, body any) *httptest.ResponseRecorder {
t.Helper()
buf, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/v1/cli-credentials/"+binaryID.String()+"/user-credentials/u1", bytes.NewReader(buf))
req.SetPathValue("id", binaryID.String())
req.SetPathValue("userId", "u1")
rec := httptest.NewRecorder()
h.handleSetUserCredentials(rec, req)
return rec
}
// 9. Handler accepts new PAT payload and routes through SetUserCredentialsTyped.
func TestPutUserCredential_PATPayload(t *testing.T) {
st := &recordingSecureCLIStore{}
h := NewSecureCLIHandler(st, nil)
binaryID := uuid.New()
rec := putUserCred(t, h, binaryID, map[string]any{
"credential_type": "pat",
"host_scope": "github.com",
"blob": map[string]string{"token": "ghp_abcDEF123456"},
})
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if st.typedCalls != 1 || st.legacyCalls != 0 {
t.Fatalf("expected typed=1 legacy=0, got typed=%d legacy=%d", st.typedCalls, st.legacyCalls)
}
if st.lastTypedType == nil || *st.lastTypedType != "pat" {
t.Fatalf("type mismatch: %#v", st.lastTypedType)
}
if st.lastTypedScope == nil || *st.lastTypedScope != "github.com" {
t.Fatalf("scope mismatch: %#v", st.lastTypedScope)
}
// Stored bytes must be exactly the blob the runtime decodes.
var got map[string]string
if err := json.Unmarshal(st.lastTypedEnv, &got); err != nil {
t.Fatal(err)
}
if got["token"] != "ghp_abcDEF123456" {
t.Fatalf("stored token mismatch: %#v", got)
}
// Response body must never echo the secret.
if strings.Contains(rec.Body.String(), "ghp_abcDEF") {
t.Fatalf("response leaked token: %s", rec.Body.String())
}
}
// 10. Handler rejects passphrase-protected SSH key with error_key.
func TestPutUserCredential_RejectsPassphraseKey(t *testing.T) {
st := &recordingSecureCLIStore{}
h := NewSecureCLIHandler(st, nil)
binaryID := uuid.New()
pem := genPassphrasePEM(t, "topsecret")
rec := putUserCred(t, h, binaryID, map[string]any{
"credential_type": "ssh_key",
"host_scope": "github.com",
"blob": map[string]string{"key": string(pem)},
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp["error_key"] != "git.cred_ssh_passphrase_unsupported" {
t.Fatalf("expected error_key=git.cred_ssh_passphrase_unsupported, got %#v", resp)
}
if st.typedCalls != 0 || st.legacyCalls != 0 {
t.Fatalf("no DB write expected on passphrase reject, got typed=%d legacy=%d", st.typedCalls, st.legacyCalls)
}
}
// 11. Handler rejects PAT/SSH payload missing host_scope.
func TestPutUserCredential_PATNoHostScope(t *testing.T) {
st := &recordingSecureCLIStore{}
h := NewSecureCLIHandler(st, nil)
binaryID := uuid.New()
rec := putUserCred(t, h, binaryID, map[string]any{
"credential_type": "pat",
"blob": map[string]string{"token": "ghp_xyz"},
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var resp map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["error_key"] != "git.cred_host_scope_required" {
t.Fatalf("expected error_key=git.cred_host_scope_required, got %#v", resp)
}
if st.typedCalls != 0 {
t.Fatalf("no DB write expected on validation reject")
}
}
// 12. Legacy env-paste body keeps the legacy code path unchanged.
func TestPutUserCredential_LegacyEnvUnchanged(t *testing.T) {
st := &recordingSecureCLIStore{}
h := NewSecureCLIHandler(st, nil)
binaryID := uuid.New()
rec := putUserCred(t, h, binaryID, map[string]any{
"env": map[string]string{"GH_TOKEN": "ghp_legacy"},
})
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if st.legacyCalls != 1 || st.typedCalls != 0 {
t.Fatalf("expected legacy=1 typed=0, got legacy=%d typed=%d", st.legacyCalls, st.typedCalls)
}
}
// 11b. SSH key CRLF normalization — pasted Windows-encoded key must be saved
// as LF so ssh.ParsePrivateKey at exec time succeeds. Belt-and-suspenders
// since the validator already runs on the normalized bytes.
func TestPutUserCredential_SSHKeyCRLFNormalized(t *testing.T) {
st := &recordingSecureCLIStore{}
h := NewSecureCLIHandler(st, nil)
binaryID := uuid.New()
pem := genUnencryptedPEM(t)
crlf := strings.ReplaceAll(string(pem), "\n", "\r\n")
rec := putUserCred(t, h, binaryID, map[string]any{
"credential_type": "ssh_key",
"host_scope": "github.com",
"blob": map[string]string{"key": crlf},
})
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var stored map[string]string
if err := json.Unmarshal(st.lastTypedEnv, &stored); err != nil {
t.Fatal(err)
}
if strings.Contains(stored["key"], "\r") {
t.Fatalf("CRLF leaked into stored key: %q", stored["key"])
}
}
// genUnencryptedPEM mirrors the helper in tools/credential_adapter_git_ssh_test
// but lives here to avoid cross-package test deps.
func genUnencryptedPEM(t *testing.T) []byte {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen: %v", err)
}
block, err := ssh.MarshalPrivateKey(priv, "")
if err != nil {
t.Fatalf("marshal: %v", err)
}
return []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n" +
base64Wrap(block.Bytes) +
"\n-----END OPENSSH PRIVATE KEY-----\n")
}
func genPassphrasePEM(t *testing.T, pw string) []byte {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen: %v", err)
}
block, err := ssh.MarshalPrivateKeyWithPassphrase(priv, "", []byte(pw))
if err != nil {
t.Fatalf("marshal: %v", err)
}
return []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n" +
base64Wrap(block.Bytes) +
"\n-----END OPENSSH PRIVATE KEY-----\n")
}
func base64Wrap(b []byte) string {
s := base64.StdEncoding.EncodeToString(b)
var out strings.Builder
for i := 0; i < len(s); i += 70 {
end := i + 70
if end > len(s) {
end = len(s)
}
if i > 0 {
out.WriteByte('\n')
}
out.WriteString(s[i:end])
}
return out.String()
}
+67 -25
View File
@@ -23,30 +23,43 @@ func (h *SecureCLIHandler) handleListUserCredentials(w http.ResponseWriter, r *h
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
return
}
// Return without env values for listing (names only + timestamps)
// Return without env values for listing (names only + timestamps).
// credential_type + host_scope surface so the table can show a Type badge
// (PAT / SSH / Env) without a second round-trip per row.
type entry struct {
ID uuid.UUID `json:"id"`
BinaryID uuid.UUID `json:"binary_id"`
UserID string `json:"user_id"`
HasEnv bool `json:"has_env"`
EnvKeys []string `json:"env_keys,omitempty"`
Env map[string]store.SecureCLIEnvResponseEntry `json:"env,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID uuid.UUID `json:"id"`
BinaryID uuid.UUID `json:"binary_id"`
UserID string `json:"user_id"`
HasEnv bool `json:"has_env"`
EnvKeys []string `json:"env_keys,omitempty"`
Env map[string]store.SecureCLIEnvResponseEntry `json:"env,omitempty"`
CredentialType *string `json:"credential_type,omitempty"`
HostScope *string `json:"host_scope,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
entries := make([]entry, 0, len(creds))
for _, c := range creds {
envKeys := envKeysFromDecryptedJSON(c.EncryptedEnv)
entries = append(entries, entry{
ID: c.ID,
BinaryID: c.BinaryID,
UserID: c.UserID,
HasEnv: len(c.EncryptedEnv) > 0,
EnvKeys: envKeys,
Env: store.SanitizeSecureCLIEnvJSON(c.EncryptedEnv),
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
})
// Typed credentials (pat / ssh_key) hold a single secret in the blob.
// Suppress env / env_keys for them — those keys would leak the wire
// shape (`token` / `key`) to the listing endpoint.
isTyped := c.CredentialType != nil && *c.CredentialType != "" && *c.CredentialType != "env"
e := entry{
ID: c.ID,
BinaryID: c.BinaryID,
UserID: c.UserID,
HasEnv: len(c.EncryptedEnv) > 0,
CredentialType: c.CredentialType,
HostScope: c.HostScope,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if !isTyped {
e.EnvKeys = envKeys
e.Env = store.SanitizeSecureCLIEnvJSON(c.EncryptedEnv)
}
entries = append(entries, e)
}
writeJSON(w, http.StatusOK, map[string]any{"user_credentials": entries})
}
@@ -75,10 +88,18 @@ func (h *SecureCLIHandler) handleGetUserCredentials(w http.ResponseWriter, r *ht
return
}
writeJSON(w, http.StatusOK, map[string]any{
"user_id": cred.UserID,
"env": store.SanitizeSecureCLIEnvJSON(cred.EncryptedEnv),
})
// Typed creds suppress env to avoid leaking the blob shape (`token`/`key`).
isTyped := cred.CredentialType != nil && *cred.CredentialType != "" && *cred.CredentialType != "env"
resp := map[string]any{
"user_id": cred.UserID,
"credential_type": cred.CredentialType,
"host_scope": cred.HostScope,
"has_secret": len(cred.EncryptedEnv) > 0,
}
if !isTyped {
resp["env"] = store.SanitizeSecureCLIEnvJSON(cred.EncryptedEnv)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *SecureCLIHandler) handleSetUserCredentials(w http.ResponseWriter, r *http.Request) {
@@ -96,10 +117,33 @@ func (h *SecureCLIHandler) handleSetUserCredentials(w http.ResponseWriter, r *ht
var body struct {
Env json.RawMessage `json:"env"`
typedCredentialBody
}
if !bindJSON(w, r, locale, &body) {
return
}
// Typed branch (pat / ssh_key): validate + encrypt blob, store with
// credential_type + host_scope. Audit emits credential_type only — never
// the secret or host (host is operator-visible config but still scoped to
// the audit channel).
envBytes, credType, hostScope, terr := prepareTypedCredentialEnv(locale, body.typedCredentialBody)
if terr != nil {
writeTypedCredentialError(w, terr)
return
}
if envBytes != nil {
if err := h.store.SetUserCredentialsTyped(r.Context(), binaryID, userID, envBytes, credType, hostScope); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
return
}
emitAudit(h.msgBus, r, "secure_cli.user_credentials.updated", "secure_cli_user_credentials", binaryID.String()+"/"+userID+"#"+*credType)
h.emitCacheInvalidate("")
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
// Legacy env-paste branch (no credential_type or credential_type="env").
if len(body.Env) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "env is required"})
return
@@ -107,7 +151,6 @@ func (h *SecureCLIHandler) handleSetUserCredentials(w http.ResponseWriter, r *ht
existing, err := h.store.GetUserCredentials(r.Context(), binaryID, userID)
if err != nil {
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
return
}
@@ -122,7 +165,6 @@ func (h *SecureCLIHandler) handleSetUserCredentials(w http.ResponseWriter, r *ht
}
if err := h.store.SetUserCredentials(r.Context(), binaryID, userID, envJSON); err != nil {
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
return
}
+14
View File
@@ -307,6 +307,20 @@ func init() {
MsgGrantEnvTooManyKeys: "too many env keys: max 50",
MsgGrantEnvRevealLimit: "rate limit exceeded for env reveal — try again later",
// Git credential adapter
MsgGitCredHostMismatch: "stored git credential is for %s but command targets %s",
MsgGitCredNoMatch: "no git credential configured for host %s",
MsgGitCredUnsupportedType: "git credential type %q is not supported",
MsgGitCredTokenInvalid: "stored git token is invalid or empty",
MsgGitCredTokenControlChar: "stored git token contains forbidden control characters",
MsgGitCredHostUserinfoRejected: "git URL with embedded userinfo is rejected as ambiguous",
MsgGitCredSSHPassphraseUnsupported: "passphrase-protected SSH keys are not supported; remove the passphrase with `ssh-keygen -p` before saving",
MsgGitCredSSHKeyInvalid: "SSH private key invalid: %s",
MsgGitCredHostScopeRequired: "host_scope is required for credential_type %s",
MsgGitCredHostScopeInvalid: "host_scope %q is not a valid hostname",
MsgGitCredBlobMissingField: "credential blob is missing required field %q",
MsgGitCredUnsupportedCredType: "credential_type %q is not supported",
// Message tool cross-target forward notice
MessageCrossTargetForwarded: "📤 Forwarded to %s as requested: %q",
+14
View File
@@ -307,6 +307,20 @@ func init() {
MsgGrantEnvTooManyKeys: "quá nhiều khóa env: tối đa 50",
MsgGrantEnvRevealLimit: "đã vượt giới hạn yêu cầu xem env — vui lòng thử lại sau",
// Git credential adapter
MsgGitCredHostMismatch: "thông tin xác thực git đã lưu dành cho %s nhưng lệnh đang nhắm tới %s",
MsgGitCredNoMatch: "không có thông tin xác thực git cho host %s",
MsgGitCredUnsupportedType: "loại thông tin xác thực git %q không được hỗ trợ",
MsgGitCredTokenInvalid: "token git đã lưu không hợp lệ hoặc rỗng",
MsgGitCredTokenControlChar: "token git đã lưu chứa ký tự điều khiển bị cấm",
MsgGitCredHostUserinfoRejected: "URL git chứa userinfo nhúng bị từ chối vì gây nhập nhằng",
MsgGitCredSSHPassphraseUnsupported: "khóa SSH có passphrase chưa được hỗ trợ; chạy `ssh-keygen -p` để gỡ passphrase trước khi lưu",
MsgGitCredSSHKeyInvalid: "khóa riêng SSH không hợp lệ: %s",
MsgGitCredHostScopeRequired: "host_scope là bắt buộc cho credential_type %s",
MsgGitCredHostScopeInvalid: "host_scope %q không phải là tên máy chủ hợp lệ",
MsgGitCredBlobMissingField: "blob credential thiếu trường bắt buộc %q",
MsgGitCredUnsupportedCredType: "credential_type %q không được hỗ trợ",
// Message tool cross-target forward notice
MessageCrossTargetForwarded: "📤 Đã forward sang %s theo yêu cầu: %q",
+14
View File
@@ -307,6 +307,20 @@ func init() {
MsgGrantEnvTooManyKeys: "环境变量键过多:最多 50 个",
MsgGrantEnvRevealLimit: "env 查看请求超出速率限制,请稍后再试",
// Git 凭据适配器
MsgGitCredHostMismatch: "已存储的 git 凭据属于 %s,但命令目标是 %s",
MsgGitCredNoMatch: "没有为主机 %s 配置 git 凭据",
MsgGitCredUnsupportedType: "不支持的 git 凭据类型 %q",
MsgGitCredTokenInvalid: "已存储的 git token 无效或为空",
MsgGitCredTokenControlChar: "已存储的 git token 包含被禁止的控制字符",
MsgGitCredHostUserinfoRejected: "git URL 中嵌入的 userinfo 因含义不明被拒绝",
MsgGitCredSSHPassphraseUnsupported: "暂不支持带密码短语的 SSH 私钥;请先用 `ssh-keygen -p` 移除密码短语再保存",
MsgGitCredSSHKeyInvalid: "SSH 私钥无效:%s",
MsgGitCredHostScopeRequired: "credential_type %s 需要 host_scope",
MsgGitCredHostScopeInvalid: "host_scope %q 不是有效的主机名",
MsgGitCredBlobMissingField: "凭据 blob 缺少必填字段 %q",
MsgGitCredUnsupportedCredType: "不支持的 credential_type %q",
// Message tool cross-target forward notice
MessageCrossTargetForwarded: "📤 已按请求转发至 %s:%q",
+46
View File
@@ -0,0 +1,46 @@
// Phase 5: catalog parity for all Git credential keys.
//
// Why a dedicated test: a new git i18n key added to keys.go without
// translations in all three catalogs would silently fall back to the key
// string in production for vi/zh users. This test enumerates every
// MsgGitCred* constant via the registered catalogs and asserts each locale
// returns a non-key, non-empty message.
package i18n
import (
"strings"
"testing"
)
// gitKeys lists every git credential key. Kept hand-curated (one line each)
// rather than reflect-walked because the constants are in a single block in
// keys.go and adding a new one is a one-line change here too.
var gitKeys = []string{
MsgGitCredHostMismatch,
MsgGitCredNoMatch,
MsgGitCredUnsupportedType,
MsgGitCredTokenInvalid,
MsgGitCredTokenControlChar,
MsgGitCredHostUserinfoRejected,
MsgGitCredSSHPassphraseUnsupported,
MsgGitCredSSHKeyInvalid,
MsgGitCredHostScopeRequired,
MsgGitCredHostScopeInvalid,
MsgGitCredBlobMissingField,
MsgGitCredUnsupportedCredType,
}
func TestI18nCatalogs_HasGitKeys(t *testing.T) {
for _, locale := range []string{LocaleEN, LocaleVI, LocaleZH} {
for _, key := range gitKeys {
msg := lookup(locale, key)
if msg == key {
t.Errorf("locale=%s key=%s falls back to key string (translation missing)", locale, key)
continue
}
if strings.TrimSpace(msg) == "" {
t.Errorf("locale=%s key=%s has empty translation", locale, key)
}
}
}
}
+14
View File
@@ -338,4 +338,18 @@ const (
MsgGrantEnvValueInvalid = "error.grant_env_value_invalid" // "invalid env value: %s"
MsgGrantEnvTooManyKeys = "error.grant_env_too_many_keys" // "too many env keys: max 50"
MsgGrantEnvRevealLimit = "error.grant_env_reveal_limit" // "rate limit exceeded for env reveal"
// --- Git credential adapter (Phase 3+) ---
MsgGitCredHostMismatch = "error.git_cred_host_mismatch" // "stored credential is for %s but command targets %s"
MsgGitCredNoMatch = "error.git_cred_no_match" // "no git credential configured for host %s"
MsgGitCredUnsupportedType = "error.git_cred_unsupported_type" // "git credential type %q is not supported"
MsgGitCredTokenInvalid = "error.git_cred_token_invalid" // "stored git token is invalid or empty"
MsgGitCredTokenControlChar = "error.git_cred_token_control_char" // "stored git token contains forbidden control characters"
MsgGitCredHostUserinfoRejected = "error.git_cred_host_userinfo_rejected" // "git URL with embedded userinfo is rejected as ambiguous"
MsgGitCredSSHPassphraseUnsupported = "error.git_cred_ssh_passphrase_unsupported" // "passphrase-protected SSH keys not supported in v1"
MsgGitCredSSHKeyInvalid = "error.git_cred_ssh_key_invalid" // "SSH private key invalid: %s"
MsgGitCredHostScopeRequired = "error.git_cred_host_scope_required" // "host_scope required for credential_type %s"
MsgGitCredHostScopeInvalid = "error.git_cred_host_scope_invalid" // "host_scope %q is not a valid hostname"
MsgGitCredBlobMissingField = "error.git_cred_blob_missing_field" // "blob missing required field %q"
MsgGitCredUnsupportedCredType = "error.git_cred_unsupported_cred_type" // "credential_type %q is not supported"
)
+22 -15
View File
@@ -27,12 +27,14 @@ func NewPGSecureCLIStore(db *sql.DB, encryptionKey string) *PGSecureCLIStore {
}
const secureCLISelectCols = `id, binary_name, binary_path, description, encrypted_env,
deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by, created_at, updated_at`
deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by,
adapter_name, created_at, updated_at`
// secureCLISelectColsAliased is prefixed with table alias "b."
// Required for LookupByBinary which uses LEFT JOIN (ambiguous column names without prefix).
const secureCLISelectColsAliased = `b.id, b.binary_name, b.binary_path, b.description, b.encrypted_env,
b.deny_args, b.deny_verbose, b.timeout_seconds, b.tips, b.is_global, b.enabled, b.created_by, b.created_at, b.updated_at`
b.deny_args, b.deny_verbose, b.timeout_seconds, b.tips, b.is_global, b.enabled, b.created_by,
b.adapter_name, b.created_at, b.updated_at`
func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary) error {
if err := store.ValidateUserID(b.CreatedBy); err != nil {
@@ -69,14 +71,15 @@ func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary)
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_binaries (id, binary_name, binary_path, description, encrypted_env,
deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by, created_at, updated_at, tenant_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by,
adapter_name, created_at, updated_at, tenant_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
b.ID, b.BinaryName, nilStr(derefStr(b.BinaryPath)), b.Description,
envBytes,
jsonOrEmptyArray(b.DenyArgs), jsonOrEmptyArray(b.DenyVerbose),
b.TimeoutSeconds, b.Tips,
b.IsGlobal, b.Enabled,
b.CreatedBy, now, now, tenantID,
b.CreatedBy, b.AdapterName, now, now, tenantID,
)
return err
}
@@ -106,7 +109,7 @@ func (s *PGSecureCLIStore) scanRow(row *sql.Row) (*store.SecureCLIBinary, error)
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
&b.Enabled, &b.CreatedBy, &b.AdapterName, &b.CreatedAt, &b.UpdatedAt,
)
if err != nil {
return nil, err
@@ -148,7 +151,7 @@ func (s *PGSecureCLIStore) scanRows(rows *sql.Rows) ([]store.SecureCLIBinary, er
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
&b.Enabled, &b.CreatedBy, &b.AdapterName, &b.CreatedAt, &b.UpdatedAt,
); err != nil {
continue
}
@@ -178,7 +181,7 @@ var secureCLIAllowedFields = map[string]bool{
"binary_name": true, "binary_path": true, "description": true,
"encrypted_env": true, "deny_args": true, "deny_verbose": true,
"timeout_seconds": true, "tips": true, "is_global": true, "enabled": true,
"updated_at": true,
"adapter_name": true, "updated_at": true,
}
func (s *PGSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
@@ -299,7 +302,7 @@ func (s *PGSecureCLIStore) scanRowsWithGrants(rows *sql.Rows) ([]store.SecureCLI
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
&b.Enabled, &b.CreatedBy, &b.AdapterName, &b.CreatedAt, &b.UpdatedAt,
&grantsJSON,
); err != nil {
continue
@@ -348,9 +351,9 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string
var joinClause string
if userID != "" {
selectCols += ", uc.encrypted_env AS user_env"
selectCols += ", uc.encrypted_env AS user_env, uc.credential_type AS user_cred_type, uc.host_scope AS user_host_scope"
} else {
selectCols += ", NULL AS user_env"
selectCols += ", NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope"
}
var args []any
@@ -424,16 +427,17 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu
var grantID *uuid.UUID
var grantEncEnv []byte
var userEnv []byte
var userCredType, userHostScope *string
err := row.Scan(
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
&b.Enabled, &b.CreatedBy, &b.AdapterName, &b.CreatedAt, &b.UpdatedAt,
// Grant columns
&grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantEnabled, &grantID, &grantEncEnv,
// User env
&userEnv,
// User credential columns
&userEnv, &userCredType, &userHostScope,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -487,6 +491,9 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu
b.UserEnv = []byte(decrypted)
}
}
// Project per-user credential metadata so adapters can branch on it.
b.UserCredentialType = userCredType
b.UserHostScope = userHostScope
return &b, nil
}
@@ -591,7 +598,7 @@ func (s *PGSecureCLIStore) ListForAgent(ctx context.Context, agentID uuid.UUID)
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
&b.Enabled, &b.CreatedBy, &b.AdapterName, &b.CreatedAt, &b.UpdatedAt,
&grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantID, &grantEncEnv,
); err != nil {
continue
@@ -13,6 +13,11 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// userCredSelectCols projects every column callers read off SecureCLIUserCredential.
// Keep in lockstep with the struct in internal/store/secure_cli_store.go.
const userCredSelectCols = `id, binary_id, user_id, encrypted_env, metadata,
credential_type, host_scope, created_at, updated_at`
func (s *PGSecureCLIStore) GetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) (*store.SecureCLIUserCredential, error) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
@@ -21,11 +26,12 @@ func (s *PGSecureCLIStore) GetUserCredentials(ctx context.Context, binaryID uuid
var uc store.SecureCLIUserCredential
var env []byte
err := s.db.QueryRowContext(ctx,
`SELECT id, binary_id, user_id, encrypted_env, metadata, created_at, updated_at
`SELECT `+userCredSelectCols+`
FROM secure_cli_user_credentials
WHERE binary_id = $1 AND user_id = $2 AND tenant_id = $3`,
binaryID, userID, tid,
).Scan(&uc.ID, &uc.BinaryID, &uc.UserID, &env, &uc.Metadata, &uc.CreatedAt, &uc.UpdatedAt)
).Scan(&uc.ID, &uc.BinaryID, &uc.UserID, &env, &uc.Metadata,
&uc.CredentialType, &uc.HostScope, &uc.CreatedAt, &uc.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -43,7 +49,15 @@ func (s *PGSecureCLIStore) GetUserCredentials(ctx context.Context, binaryID uuid
return &uc, nil
}
// SetUserCredentials writes a legacy env-vars credential (credential_type / host_scope NULL).
// Preserves all pre-existing callers.
func (s *PGSecureCLIStore) SetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte) error {
return s.SetUserCredentialsTyped(ctx, binaryID, userID, encryptedEnv, nil, nil)
}
// SetUserCredentialsTyped is the typed-credential entry point.
// credentialType / hostScope are NULL for legacy env-only credentials.
func (s *PGSecureCLIStore) SetUserCredentialsTyped(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte, credentialType, hostScope *string) error {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
@@ -62,12 +76,16 @@ func (s *PGSecureCLIStore) SetUserCredentials(ctx context.Context, binaryID uuid
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_user_credentials (binary_id, user_id, encrypted_env, metadata, tenant_id, created_at, updated_at)
VALUES ($1, $2, $3, '{}', $4, $5, $5)
`INSERT INTO secure_cli_user_credentials
(binary_id, user_id, encrypted_env, metadata, tenant_id,
credential_type, host_scope, created_at, updated_at)
VALUES ($1, $2, $3, '{}', $4, $5, $6, $7, $7)
ON CONFLICT (binary_id, user_id, tenant_id) DO UPDATE SET
encrypted_env = EXCLUDED.encrypted_env,
updated_at = EXCLUDED.updated_at`,
binaryID, userID, envBytes, tid, now,
encrypted_env = EXCLUDED.encrypted_env,
credential_type = EXCLUDED.credential_type,
host_scope = EXCLUDED.host_scope,
updated_at = EXCLUDED.updated_at`,
binaryID, userID, envBytes, tid, credentialType, hostScope, now,
)
return err
}
@@ -90,7 +108,7 @@ func (s *PGSecureCLIStore) ListUserCredentials(ctx context.Context, binaryID uui
tid = store.MasterTenantID
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, binary_id, user_id, encrypted_env, metadata, created_at, updated_at
`SELECT `+userCredSelectCols+`
FROM secure_cli_user_credentials
WHERE binary_id = $1 AND tenant_id = $2
ORDER BY created_at`, binaryID, tid)
@@ -103,7 +121,8 @@ func (s *PGSecureCLIStore) ListUserCredentials(ctx context.Context, binaryID uui
for rows.Next() {
var uc store.SecureCLIUserCredential
var env []byte
if err := rows.Scan(&uc.ID, &uc.BinaryID, &uc.UserID, &env, &uc.Metadata, &uc.CreatedAt, &uc.UpdatedAt); err != nil {
if err := rows.Scan(&uc.ID, &uc.BinaryID, &uc.UserID, &env, &uc.Metadata,
&uc.CredentialType, &uc.HostScope, &uc.CreatedAt, &uc.UpdatedAt); err != nil {
return nil, err
}
if len(env) > 0 && s.encKey != "" {
+20 -1
View File
@@ -34,7 +34,16 @@ type SecureCLIBinary struct {
IsGlobal bool `json:"is_global" db:"is_global"`
Enabled bool `json:"enabled" db:"enabled"`
CreatedBy string `json:"created_by" db:"created_by"`
UserEnv []byte `json:"-" db:"-"` // per-user encrypted env (populated by LookupByBinary LEFT JOIN)
// AdapterName routes the binary to a CredentialAdapter at exec time.
// NULL/empty → passthrough adapter (legacy env-vars injection only).
// Non-empty (e.g. "git") → typed adapter resolved via tools.LookupAdapter.
AdapterName *string `json:"adapter_name,omitempty" db:"adapter_name"`
UserEnv []byte `json:"-" db:"-"` // per-user encrypted env (populated by LookupByBinary LEFT JOIN)
// UserCredentialType + UserHostScope mirror the joined user-credential row
// (populated by LookupByBinary). NULL when the user has no credential or the
// credential is legacy env-only.
UserCredentialType *string `json:"-" db:"-"`
UserHostScope *string `json:"-" db:"-"`
// EnvKeys is set by HTTP handlers only (names from decrypted env, no values); not a DB column.
EnvKeys []string `json:"env_keys,omitempty" db:"-"`
// Env is set by HTTP handlers only. Sensitive values are masked; value entries are visible.
@@ -77,6 +86,12 @@ type SecureCLIUserCredential struct {
UpdatedAt string `json:"updated_at" db:"updated_at"`
// EncryptedEnv is decrypted JSON — never serialized to API.
EncryptedEnv []byte `json:"-" db:"encrypted_env"`
// CredentialType selects the wire shape carried in EncryptedEnv.
// NULL/empty → legacy env-vars map. Future: 'pat', 'ssh_key', 'pg_password_file'.
CredentialType *string `json:"credential_type,omitempty" db:"credential_type"`
// HostScope binds the credential to a specific hostname (e.g. 'github.com').
// Required when CredentialType ∈ {'pat','ssh_key'}; NULL for legacy env creds.
HostScope *string `json:"host_scope,omitempty" db:"host_scope"`
}
// SecureCLIAgentGrant represents a per-agent grant with optional setting overrides.
@@ -135,6 +150,10 @@ type SecureCLIStore interface {
GetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) (*SecureCLIUserCredential, error)
SetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte) error
// SetUserCredentialsTyped writes a typed user credential (PAT, SSH key, etc.).
// credentialType and hostScope are nil for legacy env-vars credentials —
// in that case behavior is identical to SetUserCredentials.
SetUserCredentialsTyped(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte, credentialType, hostScope *string) error
DeleteUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) error
ListUserCredentials(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIUserCredential, error)
}
+14 -1
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 39
const SchemaVersion = 42
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -765,6 +765,13 @@ CREATE INDEX IF NOT EXISTS idx_browser_cookies_scope_domain
ON browser_cookies (tenant_id, user_id, agent_id, domain);
CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at
ON browser_cookies (expires_at);`,
// Version 39 → 40: credential adapter framework — credential_type on user creds.
39: `ALTER TABLE secure_cli_user_credentials ADD COLUMN credential_type TEXT;`,
// Version 40 → 41: credential adapter framework — host_scope on user creds.
40: `ALTER TABLE secure_cli_user_credentials ADD COLUMN host_scope TEXT;`,
// Version 41 → 42: credential adapter framework — adapter_name on binaries.
41: `ALTER TABLE secure_cli_binaries ADD COLUMN adapter_name TEXT;`,
}
// addHooksTables is the SQLite incremental migration for schema v19 → v20.
@@ -1043,6 +1050,12 @@ func idempotentColumnMigration(version int) (string, string, bool) {
return "agents", "model_fallback", true
case 34:
return "skill_agent_grants", "can_manage", true
case 39:
return "secure_cli_user_credentials", "credential_type", true
case 40:
return "secure_cli_user_credentials", "host_scope", true
case 41:
return "secure_cli_binaries", "adapter_name", true
default:
return "", "", false
}
+11 -8
View File
@@ -1208,6 +1208,7 @@ CREATE TABLE IF NOT EXISTS secure_cli_binaries (
enabled BOOLEAN NOT NULL DEFAULT 1,
created_by TEXT NOT NULL DEFAULT '',
tenant_id TEXT NOT NULL REFERENCES tenants(id),
adapter_name TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
@@ -1501,14 +1502,16 @@ CREATE INDEX IF NOT EXISTS idx_kg_dedup_agent ON kg_dedup_candidates(agent_id, s
-- ============================================================
CREATE TABLE IF NOT EXISTS secure_cli_user_credentials (
id TEXT NOT NULL PRIMARY KEY,
binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
user_id VARCHAR(255) NOT NULL,
encrypted_env BLOB NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
tenant_id TEXT NOT NULL REFERENCES tenants(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
id TEXT NOT NULL PRIMARY KEY,
binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
user_id VARCHAR(255) NOT NULL,
encrypted_env BLOB NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
tenant_id TEXT NOT NULL REFERENCES tenants(id),
credential_type TEXT,
host_scope TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(binary_id, user_id, tenant_id)
);
@@ -15,6 +15,11 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// userCredSelectCols projects every column callers read off SecureCLIUserCredential.
// Keep in lockstep with the struct in internal/store/secure_cli_store.go.
const userCredSelectCols = `id, binary_id, user_id, encrypted_env, COALESCE(metadata, '{}'),
credential_type, host_scope, created_at, updated_at`
// GetUserCredentials returns per-user credential overrides for a CLI binary.
// Returns (nil, nil) if no per-user credentials exist.
func (s *SQLiteSecureCLIStore) GetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) (*store.SecureCLIUserCredential, error) {
@@ -25,14 +30,16 @@ func (s *SQLiteSecureCLIStore) GetUserCredentials(ctx context.Context, binaryID
var uc store.SecureCLIUserCredential
var env []byte
var metaBytes []byte
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`SELECT id, binary_id, user_id, encrypted_env, COALESCE(metadata, '{}'), created_at, updated_at
`SELECT `+userCredSelectCols+`
FROM secure_cli_user_credentials
WHERE binary_id = ? AND user_id = ? AND tenant_id = ?`,
binaryID, userID, tid,
).Scan(&uc.ID, &uc.BinaryID, &uc.UserID, &env, &uc.Metadata, &createdAt, &updatedAt)
).Scan(&uc.ID, &uc.BinaryID, &uc.UserID, &env, &metaBytes,
&uc.CredentialType, &uc.HostScope, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -43,6 +50,9 @@ func (s *SQLiteSecureCLIStore) GetUserCredentials(ctx context.Context, binaryID
uc.CreatedAt = createdAt
uc.UpdatedAt = updatedAt
if len(metaBytes) > 0 {
uc.Metadata = metaBytes
}
// Decrypt env
if len(env) > 0 && s.encKey != "" {
@@ -56,9 +66,15 @@ func (s *SQLiteSecureCLIStore) GetUserCredentials(ctx context.Context, binaryID
return &uc, nil
}
// SetUserCredentials creates or updates per-user encrypted env overrides (upsert).
// Encrypts the env bytes before storing.
// SetUserCredentials writes a legacy env-vars credential (credential_type / host_scope NULL).
// Preserves all pre-existing callers.
func (s *SQLiteSecureCLIStore) SetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte) error {
return s.SetUserCredentialsTyped(ctx, binaryID, userID, encryptedEnv, nil, nil)
}
// SetUserCredentialsTyped is the typed-credential entry point.
// credentialType / hostScope are NULL for legacy env-only credentials.
func (s *SQLiteSecureCLIStore) SetUserCredentialsTyped(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte, credentialType, hostScope *string) error {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
@@ -79,12 +95,16 @@ func (s *SQLiteSecureCLIStore) SetUserCredentials(ctx context.Context, binaryID
id := store.GenNewID()
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_user_credentials (id, binary_id, user_id, encrypted_env, metadata, tenant_id, created_at, updated_at)
VALUES (?, ?, ?, ?, '{}', ?, ?, ?)
`INSERT INTO secure_cli_user_credentials
(id, binary_id, user_id, encrypted_env, metadata, tenant_id,
credential_type, host_scope, created_at, updated_at)
VALUES (?, ?, ?, ?, '{}', ?, ?, ?, ?, ?)
ON CONFLICT (binary_id, user_id, tenant_id) DO UPDATE SET
encrypted_env = excluded.encrypted_env,
updated_at = excluded.updated_at`,
id, binaryID, userID, envBytes, tid, now, now,
encrypted_env = excluded.encrypted_env,
credential_type = excluded.credential_type,
host_scope = excluded.host_scope,
updated_at = excluded.updated_at`,
id, binaryID, userID, envBytes, tid, credentialType, hostScope, now, now,
)
return err
}
@@ -110,7 +130,7 @@ func (s *SQLiteSecureCLIStore) ListUserCredentials(ctx context.Context, binaryID
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, binary_id, user_id, encrypted_env, COALESCE(metadata, '{}'), created_at, updated_at
`SELECT `+userCredSelectCols+`
FROM secure_cli_user_credentials
WHERE binary_id = ? AND tenant_id = ?
ORDER BY created_at`, binaryID, tid)
@@ -123,14 +143,19 @@ func (s *SQLiteSecureCLIStore) ListUserCredentials(ctx context.Context, binaryID
for rows.Next() {
var uc store.SecureCLIUserCredential
var env []byte
var metaBytes []byte
var createdAt, updatedAt string
if err := rows.Scan(&uc.ID, &uc.BinaryID, &uc.UserID, &env, &uc.Metadata, &createdAt, &updatedAt); err != nil {
if err := rows.Scan(&uc.ID, &uc.BinaryID, &uc.UserID, &env, &metaBytes,
&uc.CredentialType, &uc.HostScope, &createdAt, &updatedAt); err != nil {
return nil, err
}
uc.CreatedAt = createdAt
uc.UpdatedAt = updatedAt
if len(metaBytes) > 0 {
uc.Metadata = metaBytes
}
if len(env) > 0 && s.encKey != "" {
if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil {
+34 -26
View File
@@ -31,10 +31,12 @@ func NewSQLiteSecureCLIStore(db *sql.DB, encKey string) *SQLiteSecureCLIStore {
}
const secureCLISelectCols = `id, binary_name, binary_path, description, encrypted_env,
deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by, created_at, updated_at`
deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by,
adapter_name, created_at, updated_at`
const secureCLISelectColsAliased = `b.id, b.binary_name, b.binary_path, b.description, b.encrypted_env,
b.deny_args, b.deny_verbose, b.timeout_seconds, b.tips, b.is_global, b.enabled, b.created_by, b.created_at, b.updated_at`
b.deny_args, b.deny_verbose, b.timeout_seconds, b.tips, b.is_global, b.enabled, b.created_by,
b.adapter_name, b.created_at, b.updated_at`
func (s *SQLiteSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary) error {
if err := store.ValidateUserID(b.CreatedBy); err != nil {
@@ -71,14 +73,14 @@ func (s *SQLiteSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBin
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_binaries (id, binary_name, binary_path, description, encrypted_env,
deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by, created_at, updated_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by, adapter_name, created_at, updated_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
b.ID, b.BinaryName, nilStr(derefStr(b.BinaryPath)), b.Description,
envBytes,
jsonOrEmptyArray(b.DenyArgs), jsonOrEmptyArray(b.DenyVerbose),
b.TimeoutSeconds, b.Tips,
b.IsGlobal, b.Enabled,
b.CreatedBy, nowStr, nowStr, tenantID,
b.CreatedBy, b.AdapterName, nowStr, nowStr, tenantID,
)
return err
}
@@ -109,7 +111,7 @@ func (s *SQLiteSecureCLIStore) scanRow(row *sql.Row) (*store.SecureCLIBinary, er
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &createdAt, &updatedAt,
&b.Enabled, &b.CreatedBy, &b.AdapterName, &createdAt, &updatedAt,
)
if err != nil {
return nil, err
@@ -154,7 +156,7 @@ func (s *SQLiteSecureCLIStore) scanRows(rows *sql.Rows) ([]store.SecureCLIBinary
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &createdAt, &updatedAt,
&b.Enabled, &b.CreatedBy, &b.AdapterName, &createdAt, &updatedAt,
); err != nil {
return nil, fmt.Errorf("scan secure_cli_binaries row: %w", err)
}
@@ -186,7 +188,8 @@ var secureCLIAllowedFields = map[string]bool{
"binary_name": true, "binary_path": true, "description": true,
"encrypted_env": true, "deny_args": true, "deny_verbose": true,
"timeout_seconds": true, "tips": true, "is_global": true, "enabled": true,
"updated_at": true,
"adapter_name": true,
"updated_at": true,
}
func (s *SQLiteSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
@@ -303,7 +306,7 @@ func (s *SQLiteSecureCLIStore) scanRowsWithGrants(rows *sql.Rows) ([]store.Secur
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &createdAt, &updatedAt,
&b.Enabled, &b.CreatedBy, &b.AdapterName, &createdAt, &updatedAt,
&grantsJSON,
); err != nil {
return nil, fmt.Errorf("scan secure_cli_binaries row: %w", err)
@@ -380,18 +383,28 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st
query := `SELECT ` + selectCols
// Project user-credential columns (encrypted_env + credential_type + host_scope).
// When userID is empty we cannot reference uc_user.* — emit NULL placeholders
// so the scan column count stays stable.
hasUserJoin := userID != "" && agentID != nil
hasUserJoinNoAgent := userID != "" && agentID == nil
if hasUserJoin || hasUserJoinNoAgent {
query += `, uc_user.encrypted_env AS user_env, uc_user.credential_type AS user_cred_type, uc_user.host_scope AS user_host_scope FROM secure_cli_binaries b`
} else {
query += `, NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope FROM secure_cli_binaries b`
}
// LEFT JOIN agent grant
if agentID != nil {
query += `, uc_user.encrypted_env AS user_env FROM secure_cli_binaries b`
query += ` LEFT JOIN secure_cli_agent_grants g ON g.binary_id = b.id AND g.agent_id = ?`
args = append(args, *agentID)
} else {
query += `, NULL AS user_env FROM secure_cli_binaries b`
query += ` LEFT JOIN secure_cli_agent_grants g ON 0`
}
// LEFT JOIN user credentials
if userID != "" {
// LEFT JOIN user credentials (only when we project uc_user.*)
if hasUserJoin || hasUserJoinNoAgent {
if isCross {
query += ` LEFT JOIN secure_cli_user_credentials uc_user ON uc_user.binary_id = b.id AND uc_user.user_id = ?`
args = append(args, userID)
@@ -399,16 +412,6 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st
query += ` LEFT JOIN secure_cli_user_credentials uc_user ON uc_user.binary_id = b.id AND uc_user.user_id = ? AND uc_user.tenant_id = ?`
args = append(args, userID, tid)
}
} else {
// Rewrite: no user_env JOIN needed, replace alias reference
// Already handled by NULL above — but need to adjust query structure
// We need uc_user alias to not appear in FROM if no userID
// Simplest: LEFT JOIN on impossible condition
if agentID == nil {
// already have NULL AS user_env, skip join
} else {
query += ` LEFT JOIN secure_cli_user_credentials uc_user ON 0`
}
}
// WHERE
@@ -449,15 +452,16 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.
var grantID *uuid.UUID
var grantEncEnv []byte
var userEnv []byte
var userCredType, userHostScope *string
var createdAt, updatedAt sqliteTime
err := row.Scan(
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &createdAt, &updatedAt,
&b.Enabled, &b.CreatedBy, &b.AdapterName, &createdAt, &updatedAt,
&grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantEnabled, &grantID, &grantEncEnv,
&userEnv,
&userEnv, &userCredType, &userHostScope,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -513,6 +517,10 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.
}
}
// Typed-credential metadata from the joined user-credential row.
b.UserCredentialType = userCredType
b.UserHostScope = userHostScope
return &b, nil
}
@@ -617,7 +625,7 @@ func (s *SQLiteSecureCLIStore) ListForAgent(ctx context.Context, agentID uuid.UU
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &createdAt, &updatedAt,
&b.Enabled, &b.CreatedBy, &b.AdapterName, &createdAt, &updatedAt,
&grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantID, &grantEncEnv,
); err != nil {
return nil, fmt.Errorf("scan secure_cli_binaries row: %w", err)
@@ -0,0 +1,270 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// Phase 1 schema delta tests:
// - adapter_name column on secure_cli_binaries
// - credential_type, host_scope columns on secure_cli_user_credentials
// - LookupByBinary projects all three onto the returned struct
// - Legacy callers (no adapter, no typed credential) still produce NULL columns
//
// These tests must stay green for any future schema migration touching the
// secure-cli surface to ensure backward compatibility.
// newPhase1Store opens a fresh SQLite DB with an empty encryption key.
// Phase 1 round-trip tests verify column projection, not crypto behavior — the
// shared testEncKey constant is not 32 bytes so crypto.Encrypt rejects it.
func newPhase1Store(t *testing.T) (*SQLiteSecureCLIStore, *sql.DB) {
t.Helper()
_, db := newTestSQLiteSecureCLI(t)
return NewSQLiteSecureCLIStore(db, ""), db
}
// columnExists checks PRAGMA table_info for a column on the given SQLite table.
func columnExists(t *testing.T, db *sql.DB, table, column string) bool {
t.Helper()
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
t.Fatalf("PRAGMA table_info(%s): %v", table, err)
}
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
t.Fatalf("scan table_info row: %v", err)
}
if strings.EqualFold(name, column) {
return true
}
}
return false
}
func TestSQLite_SchemaHasPhase1Columns(t *testing.T) {
_, db := newTestSQLiteSecureCLI(t)
if !columnExists(t, db, "secure_cli_binaries", "adapter_name") {
t.Fatalf("secure_cli_binaries.adapter_name missing — migration not applied")
}
if !columnExists(t, db, "secure_cli_user_credentials", "credential_type") {
t.Fatalf("secure_cli_user_credentials.credential_type missing")
}
if !columnExists(t, db, "secure_cli_user_credentials", "host_scope") {
t.Fatalf("secure_cli_user_credentials.host_scope missing")
}
}
func TestSQLite_CreateBinary_AdapterNameRoundTrip(t *testing.T) {
s, db := newPhase1Store(t)
tid := seedTenant(t, db, "t-adapter")
ctx := store.WithTenantID(context.Background(), tid)
adapter := "git"
bin := &store.SecureCLIBinary{
BinaryName: "git",
Description: "git with PAT adapter",
IsGlobal: true,
Enabled: true,
CreatedBy: "u-tester",
AdapterName: &adapter,
EncryptedEnv: []byte(`{}`),
}
if err := s.Create(ctx, bin); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := s.Get(ctx, bin.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.AdapterName == nil || *got.AdapterName != "git" {
t.Fatalf("expected adapter_name=git, got %v", got.AdapterName)
}
}
func TestSQLite_CreateBinary_AdapterNameNilStaysNull(t *testing.T) {
s, db := newPhase1Store(t)
tid := seedTenant(t, db, "t-no-adapter")
ctx := store.WithTenantID(context.Background(), tid)
bin := &store.SecureCLIBinary{
BinaryName: "gh",
Description: "legacy env-only binary",
IsGlobal: true,
Enabled: true,
CreatedBy: "u-tester",
EncryptedEnv: []byte(`{}`),
}
if err := s.Create(ctx, bin); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := s.Get(ctx, bin.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.AdapterName != nil {
t.Fatalf("expected adapter_name NULL, got %q", *got.AdapterName)
}
}
func TestSQLite_SetUserCredentialsTyped_RoundTrip(t *testing.T) {
s, db := newPhase1Store(t)
tid := seedTenant(t, db, "t-typed-cred")
ctx := store.WithTenantID(context.Background(), tid)
binID := uuid.New()
seedBinary(t, db, tid, "git", true, true)
// Re-fetch the just-seeded binary's ID since seedBinary doesn't return it.
if err := db.QueryRow(
`SELECT id FROM secure_cli_binaries WHERE binary_name = ? AND tenant_id = ?`,
"git", tid,
).Scan(&binID); err != nil {
t.Fatalf("lookup seeded binary: %v", err)
}
credType := "pat"
hostScope := "github.com"
plaintext := []byte(`{"token":"ghp_xxx"}`)
if err := s.SetUserCredentialsTyped(ctx, binID, "u-1", plaintext, &credType, &hostScope); err != nil {
t.Fatalf("SetUserCredentialsTyped: %v", err)
}
got, err := s.GetUserCredentials(ctx, binID, "u-1")
if err != nil {
t.Fatalf("GetUserCredentials: %v", err)
}
if got == nil {
t.Fatalf("expected credential, got nil")
}
if got.CredentialType == nil || *got.CredentialType != "pat" {
t.Fatalf("expected credential_type=pat, got %v", got.CredentialType)
}
if got.HostScope == nil || *got.HostScope != "github.com" {
t.Fatalf("expected host_scope=github.com, got %v", got.HostScope)
}
if string(got.EncryptedEnv) != string(plaintext) {
t.Fatalf("decrypted env mismatch: got %q want %q", got.EncryptedEnv, plaintext)
}
}
// SetUserCredentials (legacy entrypoint) must leave credential_type / host_scope NULL.
func TestSQLite_SetUserCredentials_LegacyLeavesTypeColumnsNull(t *testing.T) {
s, db := newPhase1Store(t)
tid := seedTenant(t, db, "t-legacy-cred")
ctx := store.WithTenantID(context.Background(), tid)
seedBinary(t, db, tid, "git", true, true)
var binID uuid.UUID
if err := db.QueryRow(
`SELECT id FROM secure_cli_binaries WHERE binary_name = ? AND tenant_id = ?`,
"git", tid,
).Scan(&binID); err != nil {
t.Fatalf("lookup seeded binary: %v", err)
}
if err := s.SetUserCredentials(ctx, binID, "u-legacy", []byte(`{"GITHUB_TOKEN":"x"}`)); err != nil {
t.Fatalf("SetUserCredentials: %v", err)
}
got, err := s.GetUserCredentials(ctx, binID, "u-legacy")
if err != nil {
t.Fatalf("GetUserCredentials: %v", err)
}
if got == nil {
t.Fatalf("expected credential, got nil")
}
if got.CredentialType != nil {
t.Fatalf("expected credential_type NULL for legacy, got %q", *got.CredentialType)
}
if got.HostScope != nil {
t.Fatalf("expected host_scope NULL for legacy, got %q", *got.HostScope)
}
}
// LookupByBinary must populate AdapterName (from binary row) AND
// UserCredentialType + UserHostScope (from joined user-credential row).
func TestSQLite_LookupByBinary_ProjectsNewColumns(t *testing.T) {
s, db := newPhase1Store(t)
tid := seedTenant(t, db, "t-lookup-proj")
ctx := store.WithTenantID(context.Background(), tid)
adapter := "git"
bin := &store.SecureCLIBinary{
BinaryName: "git",
Description: "git adapter binary",
IsGlobal: true,
Enabled: true,
CreatedBy: "u-tester",
AdapterName: &adapter,
EncryptedEnv: []byte(`{}`),
}
if err := s.Create(ctx, bin); err != nil {
t.Fatalf("Create: %v", err)
}
credType := "pat"
hostScope := "github.com"
if err := s.SetUserCredentialsTyped(ctx, bin.ID, "u-1", []byte(`{"token":"x"}`), &credType, &hostScope); err != nil {
t.Fatalf("SetUserCredentialsTyped: %v", err)
}
got, err := s.LookupByBinary(ctx, "git", nil, "u-1")
if err != nil {
t.Fatalf("LookupByBinary: %v", err)
}
if got == nil {
t.Fatalf("expected binary, got nil")
}
if got.AdapterName == nil || *got.AdapterName != "git" {
t.Fatalf("expected AdapterName=git, got %v", got.AdapterName)
}
if got.UserCredentialType == nil || *got.UserCredentialType != "pat" {
t.Fatalf("expected UserCredentialType=pat, got %v", got.UserCredentialType)
}
if got.UserHostScope == nil || *got.UserHostScope != "github.com" {
t.Fatalf("expected UserHostScope=github.com, got %v", got.UserHostScope)
}
}
// Backward-compat: a binary with no adapter + no user credential must still
// return successfully from LookupByBinary with all three new fields NULL.
func TestSQLite_LookupByBinary_BackwardCompatibleNulls(t *testing.T) {
s, db := newPhase1Store(t)
tid := seedTenant(t, db, "t-bc-null")
ctx := store.WithTenantID(context.Background(), tid)
seedBinary(t, db, tid, "gh", true, true)
got, err := s.LookupByBinary(ctx, "gh", nil, "")
if err != nil {
t.Fatalf("LookupByBinary: %v", err)
}
if got == nil {
t.Fatalf("expected binary, got nil")
}
if got.AdapterName != nil {
t.Fatalf("expected AdapterName NULL, got %q", *got.AdapterName)
}
if got.UserCredentialType != nil {
t.Fatalf("expected UserCredentialType NULL, got %q", *got.UserCredentialType)
}
if got.UserHostScope != nil {
t.Fatalf("expected UserHostScope NULL, got %q", *got.UserHostScope)
}
}
+138
View File
@@ -0,0 +1,138 @@
package tools
import (
"context"
"crypto/sha256"
"encoding/hex"
"log/slog"
"sort"
"sync"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// CredentialAdapter transforms a user credential into the shape a specific CLI
// binary expects. Each binary's adapter_name column routes runtime exec to one
// of these. The default "passthrough" adapter is a no-op so unrelated presets
// (gh, aws, kubectl, gcloud, terraform, gws) keep their legacy env-injection
// path bit-for-bit.
//
// Adapters never see or log raw secret bytes — `Prepare` returns an Injection
// describing how to mutate argv/env, plus a Cleanup hook for ephemeral files
// and a ScrubValues slice that the caller registers with the per-request
// scrubber (see scrub.go WithScrubBag).
type CredentialAdapter interface {
Name() string
ShouldInject(argv []string) bool
Prepare(ctx context.Context, bin *store.SecureCLIBinary, cred *store.SecureCLIUserCredential, argv []string) (*Injection, error)
}
// Injection is the adapter's instruction set for one exec call.
//
// - ArgvPrefix is spliced in BETWEEN binary and user-supplied args (so
// `git clone X` becomes `git <prefix...> clone X`). The slice is not
// re-quoted or shell-parsed — it goes straight into exec.Command.
// - Env is merged on top of the base env after legacy ValidateGrantEnvVars
// has already vetted the user-controlled credential blob. Keys here come
// from hard-coded adapter code only.
// - Cleanup is deferred AFTER the synchronous exec returns; safe to be nil.
// - ScrubValues lists adapter-derived secrets the per-request scrubber must
// redact from stdout/stderr/error messages before they reach the LLM.
type Injection struct {
ArgvPrefix []string
Env map[string]string
Cleanup func() error
ScrubValues []string
}
var (
adaptersMu sync.RWMutex
adapters = map[string]CredentialAdapter{
"passthrough": passthroughAdapter{},
}
)
// RegisterAdapter registers a CredentialAdapter under its Name(). Idempotent —
// re-registering the same name overwrites. Intended for init() of each
// adapter file (e.g. credential_adapter_git.go in Phase 3).
func RegisterAdapter(a CredentialAdapter) {
if a == nil {
return
}
adaptersMu.Lock()
defer adaptersMu.Unlock()
adapters[a.Name()] = a
}
// AdapterFor resolves an adapter by name, falling back to passthrough on
// empty/unknown so an operator-set adapter_name typo can never break exec —
// it just degrades to legacy behavior with a clear audit trail.
func AdapterFor(name string) CredentialAdapter {
adaptersMu.RLock()
defer adaptersMu.RUnlock()
if a, ok := adapters[name]; ok {
return a
}
return adapters["passthrough"]
}
// passthroughAdapter is the default. It does nothing — preserves the legacy
// env-injection-only path so every existing preset behaves identically.
type passthroughAdapter struct{}
func (passthroughAdapter) Name() string { return "passthrough" }
func (passthroughAdapter) ShouldInject([]string) bool { return false }
func (passthroughAdapter) Prepare(context.Context, *store.SecureCLIBinary, *store.SecureCLIUserCredential, []string) (*Injection, error) {
return &Injection{}, nil
}
// hashHostScope returns the SHA-256 hex prefix of the host-scope value, or
// "none" when nil. Used in audit logs instead of plaintext hostname so SIEM
// pipelines can correlate without exposing internal infra hostnames.
func hashHostScope(hs *string) string {
if hs == nil || *hs == "" {
return "none"
}
sum := sha256.Sum256([]byte(*hs))
return hex.EncodeToString(sum[:4]) // 8 hex chars
}
// sortedKeys returns env map keys in deterministic order for audit logs.
func sortedKeys(m map[string]string) []string {
if len(m) == 0 {
return nil
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// emitSystemEnvInjectionAudit is the single source of truth for the
// `security.system_env_injection` slog line. Operators grep for this event
// name in their log aggregator (see docs/09-security.md). The field schema is
// pinned by `TestEmitSystemEnvInjectionAudit_FieldSchema` — any change here
// must update both that test and the operator-facing docs.
//
// Fields:
// - adapter: adapter name (e.g. "git", "passthrough")
// - binary: binary name (e.g. "git", "gh")
// - user_id: tenant user UUID (or empty for global-only contexts)
// - env_keys: sorted env-var NAMES (never values)
// - argv_prefix_len: number of argv elements prepended (NOT their content)
// - host_scope_hash: SHA-256 first 8 hex chars of host_scope (or "none")
func emitSystemEnvInjectionAudit(adapter, binary, userID string, inj *Injection, hostScope *string) {
if inj == nil {
return
}
slog.Warn("security.system_env_injection",
"adapter", adapter,
"user_id", userID,
"binary", binary,
"env_keys", sortedKeys(inj.Env),
"argv_prefix_len", len(inj.ArgvPrefix),
"host_scope_hash", hashHostScope(hostScope),
)
}
+445
View File
@@ -0,0 +1,445 @@
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"strings"
"golang.org/x/net/idna"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// gitAdapter implements the CredentialAdapter contract for the `git` binary.
//
// PAT path (this file): host-scoped HTTP extraheader injected via
// GIT_CONFIG_COUNT/GIT_CONFIG_KEY_0/GIT_CONFIG_VALUE_0 env vars (git 2.31+).
// Keeps the token out of /proc/<pid>/cmdline and `ps` output — the typical
// `-c http.extraHeader=...` form would land the bearer token directly in argv
// where any process listing leaks it.
//
// SSH path: Phase 4 extends the same Prepare switch.
type gitAdapter struct{}
func (gitAdapter) Name() string { return "git" }
// ShouldInject returns true only for subcommands that talk to a remote.
// Status/log/diff/commit do not need credentials, and asking the adapter for
// them would waste a `git config --get` sub-exec and emit noisy audit events.
func (gitAdapter) ShouldInject(argv []string) bool {
if len(argv) == 0 {
return false
}
switch argv[0] {
case "clone", "fetch", "pull", "push", "submodule":
return true
}
return false
}
var (
errEmptyToken = errors.New("git adapter: token is empty")
errTokenTooLong = errors.New("git adapter: token exceeds 4 KiB")
errTokenControlChar = errors.New("git adapter: token contains control character (CR/LF/NUL)")
errEmptyHost = errors.New("git adapter: host is empty")
errEmbeddedUserinfo = errors.New("git adapter: URL has embedded userinfo (ambiguous; rejected)")
errURLMissingHost = errors.New("git adapter: URL has no host")
errURLUnsupportedForm = errors.New("git adapter: URL form not recognized")
errCloneURLMissing = errors.New("git adapter: clone command has no URL argument")
errUnknownSubcommand = errors.New("git adapter: unknown subcommand")
errCredentialTypeBlank = errors.New("git adapter: credential_type required for git adapter routing")
)
// errCredentialHostMismatch is returned when the matched credential's host
// scope does not equal the target host extracted from argv (or remote URL).
// Carries both hosts in fields so callers can format an i18n message without
// re-parsing. Error.Error() intentionally omits any token-shaped data.
type errCredentialHostMismatch struct {
credHost string
targetHost string
}
func (e *errCredentialHostMismatch) Error() string {
return fmt.Sprintf("git adapter: credential host %q does not match target host %q", e.credHost, e.targetHost)
}
func (gitAdapter) Prepare(ctx context.Context, _ *store.SecureCLIBinary, cred *store.SecureCLIUserCredential, argv []string) (*Injection, error) {
if cred == nil {
return &Injection{}, nil
}
typ := ""
if cred.CredentialType != nil {
typ = *cred.CredentialType
}
// Legacy env-vars rows: behave exactly like passthrough so existing operators
// who wired `git` via env-paste before the adapter existed keep working.
if typ == "" || typ == "env" {
return &Injection{}, nil
}
// cwd from ctx so `git config --get remote.X.url` runs inside the repo
// the caller is about to operate on. Empty when caller did not set it
// (e.g. clone, where the URL is in argv and cwd is irrelevant).
host, err := resolveTargetHost(ctx, argv, ExecCwdFromContext(ctx))
if err != nil {
return nil, fmt.Errorf("resolve target host: %w", err)
}
credHostRaw := ""
if cred.HostScope != nil {
credHostRaw = *cred.HostScope
}
credHost, err := normalizeHost(credHostRaw)
if err != nil {
return nil, fmt.Errorf("normalize credential host: %w", err)
}
targetHost, err := normalizeHost(host)
if err != nil {
return nil, fmt.Errorf("normalize target host: %w", err)
}
if credHost != targetHost {
return nil, &errCredentialHostMismatch{credHost: credHost, targetHost: targetHost}
}
switch typ {
case "pat":
token, err := decodePATToken(cred.EncryptedEnv)
if err != nil {
return nil, err
}
if err := validateTokenShape(token); err != nil {
return nil, err
}
// GIT_CONFIG_COUNT env approach (git 2.31+) — same effect as
// `-c http.https://host/.extraheader=Authorization: Bearer <tok>` but
// the token stays out of argv. Single-entry header keeps host-scoping
// strict; the matching url prefix ensures git skips its credential
// helpers for this URL automatically.
configKey := fmt.Sprintf("http.https://%s/.extraheader", targetHost)
configVal := fmt.Sprintf("Authorization: Bearer %s", token)
return &Injection{
Env: map[string]string{
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": configKey,
"GIT_CONFIG_VALUE_0": configVal,
},
ScrubValues: []string{token},
}, nil
case "ssh_key":
keyPEM, err := decodeSSHKeyBlob(cred.EncryptedEnv)
if err != nil {
return nil, err
}
// Defense-in-depth: ValidateSSHKey is called at save; running it again
// here catches keys that bypassed validation (e.g. raw DB write or a
// future store mutation that skips the handler path).
if err := ValidateSSHKey(keyPEM); err != nil {
return nil, err
}
keyPath, cleanup, err := materializeEphemeral(ctx, keyPEM, "gitkey")
if err != nil {
return nil, err
}
// -o flags pinned for safety:
// IdentitiesOnly=yes — ssh must not try other keys in ~/.ssh
// BatchMode=yes — never prompt; fail fast in agent context
// StrictHostKeyChecking=accept-new — first contact TOFU; known hosts
// still enforced after
// UserKnownHostsFile=~/.ssh/known_hosts is left at default so
// operators can pre-seed pinned host keys (documented in Phase 6).
sshCmd := fmt.Sprintf(
"ssh -i %s -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
keyPath,
)
return &Injection{
Env: map[string]string{"GIT_SSH_COMMAND": sshCmd},
Cleanup: cleanup,
ScrubValues: []string{keyPath},
}, nil
default:
return nil, fmt.Errorf("git adapter: unsupported credential_type %q", typ)
}
}
// decodeSSHKeyBlob extracts the private-key PEM from the credential blob.
// v1 wire shape: `{"key":"-----BEGIN OPENSSH PRIVATE KEY-----\n..."}`.
// No legacy fallback — SSH support is new in Phase 4, so any existing
// row with credential_type='ssh_key' was written by the new wire.
func decodeSSHKeyBlob(blob []byte) ([]byte, error) {
if len(blob) == 0 {
return nil, errors.New("git adapter: empty ssh key blob")
}
var m map[string]string
if err := json.Unmarshal(blob, &m); err != nil {
return nil, fmt.Errorf("decode ssh key blob: %w", err)
}
if k, ok := m["key"]; ok && k != "" {
return []byte(k), nil
}
return nil, errors.New("git adapter: ssh key blob missing 'key' field")
}
// decodePATToken extracts the token from the credential blob. v1 wire shape
// is `{"token": "..."}`. Falls back to legacy env-style `{"GIT_TOKEN": "..."}`
// only when the typed key is missing, so operators migrating from env-paste
// rows do not need to re-enter their PAT.
func decodePATToken(blob []byte) (string, error) {
if len(blob) == 0 {
return "", errEmptyToken
}
var m map[string]string
if err := json.Unmarshal(blob, &m); err != nil {
return "", fmt.Errorf("decode pat blob: %w", err)
}
if tok, ok := m["token"]; ok {
return tok, nil
}
if tok, ok := m["GIT_TOKEN"]; ok {
return tok, nil
}
return "", errEmptyToken
}
// validateTokenShape rejects empty, oversized, and control-char tokens. The
// CR/LF check defends against header injection if the UI validator ever
// regresses — a token like "ghp_x\r\nX-Evil: y" would otherwise smuggle a
// second HTTP header through GIT_CONFIG_VALUE_0.
func validateTokenShape(tok string) error {
if tok == "" {
return errEmptyToken
}
if len(tok) > 4096 {
return errTokenTooLong
}
for _, r := range tok {
if r < 0x20 || r == 0x7f {
return errTokenControlChar
}
}
return nil
}
// normalizeHost canonicalizes a hostname for apples-to-apples comparison:
// trim whitespace + trailing dot, lowercase, idna.Lookup.ToASCII for IDN/
// punycode equivalence. Preserves explicit ports (`:8443`) verbatim.
func normalizeHost(h string) (string, error) {
h = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(h)), ".")
if h == "" {
return "", errEmptyHost
}
hostPart, port, hasPort := splitHostPortOptional(h)
ascii, err := idna.Lookup.ToASCII(hostPart)
if err != nil {
return "", fmt.Errorf("idna: %w", err)
}
if hasPort {
return ascii + ":" + port, nil
}
return ascii, nil
}
// splitHostPortOptional splits "host:port" when port is all digits; otherwise
// returns host unchanged. Avoids net.SplitHostPort because that errors on
// portless inputs and on IPv6 forms without brackets, which we don't accept.
func splitHostPortOptional(h string) (host, port string, ok bool) {
idx := strings.LastIndex(h, ":")
if idx < 0 {
return h, "", false
}
port = h[idx+1:]
if port == "" {
return h, "", false
}
for _, r := range port {
if r < '0' || r > '9' {
return h, "", false
}
}
return h[:idx], port, true
}
// resolveTargetHost finds the host this git invocation will contact. For
// clone, the URL is in argv. For fetch/pull/push/submodule, we sub-exec a
// hardened `git config --get remote.<name>.url` to read it from .git/config.
//
// cwd is the working directory for the sub-exec. Empty means current process
// dir (used by adapter Prepare in production where the exec hasn't chdir'd
// yet — git -C . is the implicit default).
func resolveTargetHost(ctx context.Context, argv []string, cwd string) (string, error) {
if len(argv) == 0 {
return "", errUnknownSubcommand
}
switch argv[0] {
case "clone":
u, err := firstNonFlagArg(argv[1:])
if err != nil {
return "", err
}
return parseHostFromGitURL(u)
case "fetch", "pull":
remote := pickRemoteFromArgv(argv[1:], "origin")
return remoteURLViaConfigGet(ctx, cwd, remote)
case "push":
remote := pickPushRemoteFromArgv(argv[1:], "origin")
return remoteURLViaConfigGet(ctx, cwd, remote)
case "submodule":
// `git submodule update` uses the parent repo's origin URL as the
// reachability base for relative submodule paths, and the submodule
// fetch reuses the parent's auth header when the host matches.
return remoteURLViaConfigGet(ctx, cwd, "origin")
}
return "", errUnknownSubcommand
}
// firstNonFlagArg returns the first argv element that does not start with `-`.
// Used to find the clone URL past option flags like --depth, --branch, etc.
func firstNonFlagArg(args []string) (string, error) {
skipValueFor := map[string]bool{
// Long options that take a separate value as the next arg.
"--branch": true, "--depth": true, "--origin": true, "--config": true,
"--reference": true, "--reference-if-able": true, "--separate-git-dir": true,
"--template": true, "--upload-pack": true, "--jobs": true, "--shallow-since": true,
"--shallow-exclude": true, "--filter": true, "--server-option": true,
"-b": true, "-o": true, "-c": true, "-j": true, "-u": true,
}
skipNext := false
for _, a := range args {
if skipNext {
skipNext = false
continue
}
if strings.HasPrefix(a, "-") {
// `--depth=1` style — value embedded; no skip.
if strings.Contains(a, "=") {
continue
}
if skipValueFor[a] {
skipNext = true
}
continue
}
return a, nil
}
return "", errCloneURLMissing
}
// pickRemoteFromArgv finds the explicit remote name in `git fetch/pull` argv,
// or returns `fallback` when none is given. Flags are skipped.
func pickRemoteFromArgv(args []string, fallback string) string {
for _, a := range args {
if strings.HasPrefix(a, "-") {
continue
}
return a
}
return fallback
}
// pickPushRemoteFromArgv handles `git push`'s extra `--repo=<remote>` shape.
func pickPushRemoteFromArgv(args []string, fallback string) string {
for _, a := range args {
if strings.HasPrefix(a, "--repo=") {
return strings.TrimPrefix(a, "--repo=")
}
}
return pickRemoteFromArgv(args, fallback)
}
// remoteURLViaConfigGet runs a hardened `git config --get remote.<name>.url`
// sub-exec to read the remote URL from .git/config without triggering any
// url-rewriting (insteadOf), credential helpers, or protocol-handler exec.
//
// We use `git config --get` instead of `git remote get-url` because the
// latter routes through git_handle_repo, which is the CVE-2018-17456 attack
// surface (malicious `[remote] url = ext::sh -c <evil>` in .git/config would
// execute arbitrary shell). The CVE has been patched in modern git, but the
// defense-in-depth pattern keeps this immune to any future regression.
//
// GIT_ALLOW_PROTOCOL allowlists what git considers valid; GIT_TERMINAL_PROMPT=0
// prevents interactive auth prompts; GIT_CONFIG_NOSYSTEM=1 ignores /etc/gitconfig
// so a host-wide rewrite cannot influence the lookup.
func remoteURLViaConfigGet(ctx context.Context, cwd, remote string) (string, error) {
args := []string{"-C", cwd, "config", "--get", "remote." + remote + ".url"}
if cwd == "" {
args = []string{"config", "--get", "remote." + remote + ".url"}
}
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Env = append(os.Environ(),
"GIT_ALLOW_PROTOCOL=https:http:ssh:git",
"GIT_TERMINAL_PROMPT=0",
"GIT_CONFIG_NOSYSTEM=1",
)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("read remote url for %q: %w", remote, err)
}
return parseHostFromGitURL(strings.TrimSpace(string(out)))
}
// parseHostFromGitURL extracts the host[:port] from a git remote URL.
// Three forms are accepted:
//
// https://host[:port]/... (HTTPS — embedded userinfo REJECTED)
// git@host:owner/repo.git (scp-form SSH — bare user, no userinfo collision)
// ssh://git@host[:port]/... (full SSH — user MUST be "git" or absent)
//
// Embedded `https://user@host/...` is rejected as ambiguous: git treats `host`
// as the target but operators reading the URL often think `user` is the host.
// An attacker who controls `host` could trick a credential-host check into
// matching the wrong side. Refuse rather than guess.
func parseHostFromGitURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errURLMissingHost
}
// scp-form: `user@host:path`. Match BEFORE generic URL parsing because
// `git@github.com:owner/repo` looks like `git@github.com:owner` host:port
// to url.Parse, mis-bucketing the path into the port.
if !strings.Contains(raw, "://") && strings.Contains(raw, ":") && strings.Contains(raw, "@") {
at := strings.Index(raw, "@")
colon := strings.Index(raw[at:], ":")
if colon > 0 {
host := raw[at+1 : at+colon]
if host == "" {
return "", errURLMissingHost
}
return host, nil
}
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("parse url: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return "", errURLUnsupportedForm
}
// Reject embedded userinfo for HTTPS/HTTP.
// Exception: ssh://git@host is the conventional and unambiguous SSH form;
// we allow it only when user is empty or exactly "git" (no password).
if u.User != nil {
switch u.Scheme {
case "https", "http":
return "", errEmbeddedUserinfo
case "ssh":
if _, hasPass := u.User.Password(); hasPass {
return "", errEmbeddedUserinfo
}
if name := u.User.Username(); name != "" && name != "git" {
return "", errEmbeddedUserinfo
}
default:
return "", errEmbeddedUserinfo
}
}
return u.Host, nil
}
func init() {
RegisterAdapter(gitAdapter{})
}
@@ -0,0 +1,50 @@
package tools
// SSH key validation for the git adapter (Phase 4, issue #82).
//
// Why this lives in tools and not store: the store layer is type-agnostic
// (it only writes encrypted bytes). The "what's a valid SSH key for this
// adapter?" rule belongs to the adapter itself. Callers (HTTP/WS save
// handlers) invoke ValidateSSHKey BEFORE encrypting and persisting.
//
// v1 scope: passphrase-protected keys are rejected. Reason: the runtime has
// nowhere to hold a passphrase for unattended `git fetch` and storing it
// alongside the key defeats the purpose. Phase 6 docs explain how operators
// can strip the passphrase via `ssh-keygen -p` before saving.
import (
"errors"
"fmt"
"golang.org/x/crypto/ssh"
)
// ErrSSHKeyPassphraseUnsupported is returned when the supplied PEM is
// passphrase-protected. Sentinel so HTTP/WS handlers can map it to the
// localized i18n message without string-matching.
var ErrSSHKeyPassphraseUnsupported = errors.New("ssh key: passphrase-protected keys not supported in v1")
// ValidatePATToken is the exported alias of validateTokenShape — HTTP/WS
// save handlers call this BEFORE encrypting so a malformed token (empty,
// oversize, control chars) is rejected with a localized error before any
// DB write or audit emit. Phase 3's runtime check still defends in depth.
func ValidatePATToken(tok string) error { return validateTokenShape(tok) }
// ValidateSSHKey parses the supplied PEM with x/crypto/ssh. Any non-nil
// error blocks the save: a key that fails to parse here will also fail at
// `ssh -i` time, and we want the diagnostic to happen at save (where the
// user can fix it) not at exec (where the agent stalls).
func ValidateSSHKey(pem []byte) error {
if len(pem) == 0 {
return errors.New("ssh key: empty PEM")
}
_, err := ssh.ParsePrivateKey(pem)
if err == nil {
return nil
}
var pme *ssh.PassphraseMissingError
if errors.As(err, &pme) {
return ErrSSHKeyPassphraseUnsupported
}
return fmt.Errorf("ssh key parse: %w", err)
}
@@ -0,0 +1,240 @@
package tools
// Phase 4 unit tests for gitAdapter's ssh_key branch and ValidateSSHKey.
// Integration coverage (real exec, tmpfile lifecycle under a child process)
// lives in tests/integration/git_adapter_ssh_test.go.
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"os"
"runtime"
"strings"
"testing"
"golang.org/x/crypto/ssh"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// genEd25519PEM returns an unencrypted OpenSSH PEM-encoded ed25519 private key.
func genEd25519PEM(t *testing.T) []byte {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 gen: %v", err)
}
block, err := ssh.MarshalPrivateKey(priv, "")
if err != nil {
t.Fatalf("marshal key: %v", err)
}
return []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n" +
base64Encode(block.Bytes) +
"\n-----END OPENSSH PRIVATE KEY-----\n")
}
// genEd25519PEMWithPassphrase returns a passphrase-encrypted ed25519 key.
func genEd25519PEMWithPassphrase(t *testing.T, pw string) []byte {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 gen: %v", err)
}
block, err := ssh.MarshalPrivateKeyWithPassphrase(priv, "", []byte(pw))
if err != nil {
t.Fatalf("marshal encrypted key: %v", err)
}
return []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n" +
base64Encode(block.Bytes) +
"\n-----END OPENSSH PRIVATE KEY-----\n")
}
// base64Encode wraps stdlib base64 at 70 cols to mimic real OpenSSH PEM.
func base64Encode(b []byte) string {
s := base64.StdEncoding.EncodeToString(b)
var out strings.Builder
for i := 0; i < len(s); i += 70 {
end := i + 70
if end > len(s) {
end = len(s)
}
if i > 0 {
out.WriteByte('\n')
}
out.WriteString(s[i:end])
}
return out.String()
}
// 1. AC5: passphrase keys rejected via sentinel.
func TestValidateSSHKey_RejectsPassphrase(t *testing.T) {
pem := genEd25519PEMWithPassphrase(t, "topsecret")
err := ValidateSSHKey(pem)
if !errors.Is(err, ErrSSHKeyPassphraseUnsupported) {
t.Fatalf("want ErrSSHKeyPassphraseUnsupported, got %v", err)
}
}
// 2. Plain ed25519 passes.
func TestValidateSSHKey_AcceptsUnencrypted(t *testing.T) {
if err := ValidateSSHKey(genEd25519PEM(t)); err != nil {
t.Fatalf("ed25519 should pass: %v", err)
}
}
// 3. Garbage rejected without exposing as passphrase.
func TestValidateSSHKey_RejectsGarbage(t *testing.T) {
err := ValidateSSHKey([]byte("not a key"))
if err == nil {
t.Fatal("expected error")
}
if errors.Is(err, ErrSSHKeyPassphraseUnsupported) {
t.Fatal("garbage must not be classified as passphrase-protected")
}
}
// 4. Empty rejected.
func TestValidateSSHKey_RejectsEmpty(t *testing.T) {
if err := ValidateSSHKey(nil); err == nil {
t.Fatal("empty must error")
}
}
// 5. Prepare(ssh_key) shape: env, options, scrub, cleanup.
func TestGitAdapter_PrepareSSH(t *testing.T) {
pem := genEd25519PEM(t)
blob, _ := json.Marshal(map[string]string{"key": string(pem)})
ct, hs := "ssh_key", "github.com"
cred := &store.SecureCLIUserCredential{
CredentialType: &ct,
HostScope: &hs,
EncryptedEnv: blob,
}
a := gitAdapter{}
inj, err := a.Prepare(context.Background(), nil, cred,
[]string{"clone", "git@github.com:o/r.git", "/tmp/dst"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if len(inj.ArgvPrefix) != 0 {
t.Fatalf("argv leak: %v", inj.ArgvPrefix)
}
gsc := inj.Env["GIT_SSH_COMMAND"]
if !strings.HasPrefix(gsc, "ssh -i ") {
t.Fatalf("GIT_SSH_COMMAND prefix wrong: %q", gsc)
}
for _, need := range []string{
"-o IdentitiesOnly=yes",
"-o BatchMode=yes",
"-o StrictHostKeyChecking=accept-new",
} {
if !strings.Contains(gsc, need) {
t.Fatalf("GIT_SSH_COMMAND missing %q\nfull: %s", need, gsc)
}
}
// Extract keypath: token after "-i ".
rest := strings.TrimPrefix(gsc, "ssh -i ")
keyPath := strings.SplitN(rest, " ", 2)[0]
if !strings.Contains(keyPath, "goclaw-gitkey-") {
t.Fatalf("keypath not in expected tmp prefix: %s", keyPath)
}
if !strings.HasPrefix(keyPath, os.TempDir()) {
t.Fatalf("keypath not under TempDir(%s): %s", os.TempDir(), keyPath)
}
// 0600 perms (POSIX only).
if runtime.GOOS != "windows" {
st, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("stat keypath: %v", err)
}
if st.Mode().Perm() != 0o600 {
t.Fatalf("keypath perms = %o, want 0600", st.Mode().Perm())
}
}
if len(inj.ScrubValues) != 1 || inj.ScrubValues[0] != keyPath {
t.Fatalf("ScrubValues = %v, want [%s]", inj.ScrubValues, keyPath)
}
if inj.Cleanup == nil {
t.Fatal("Cleanup must be non-nil")
}
// File present pre-cleanup, gone after.
if _, err := os.Stat(keyPath); err != nil {
t.Fatalf("pre-cleanup stat: %v", err)
}
if err := inj.Cleanup(); err != nil {
t.Fatalf("cleanup: %v", err)
}
if _, err := os.Stat(keyPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("post-cleanup stat want ErrNotExist, got: %v", err)
}
}
// 6. SSH path also enforces host match: scp-form mismatch.
func TestGitAdapter_PrepareSSH_HostMismatchSCPForm(t *testing.T) {
pem := genEd25519PEM(t)
blob, _ := json.Marshal(map[string]string{"key": string(pem)})
ct, hs := "ssh_key", "github.com"
cred := &store.SecureCLIUserCredential{
CredentialType: &ct,
HostScope: &hs,
EncryptedEnv: blob,
}
a := gitAdapter{}
_, err := a.Prepare(context.Background(), nil, cred,
[]string{"clone", "git@gitlab.com:o/r.git"})
if err == nil {
t.Fatal("expected host mismatch")
}
var mismatch *errCredentialHostMismatch
if !errors.As(err, &mismatch) {
t.Fatalf("want *errCredentialHostMismatch, got %T: %v", err, err)
}
if strings.Contains(err.Error(), string(pem)) {
t.Fatal("key PEM leaked into error message")
}
}
// 7. Cleanup is idempotent — Phase 2b lockstep test.
func TestGitAdapter_PrepareSSH_CleanupIdempotent(t *testing.T) {
pem := genEd25519PEM(t)
blob, _ := json.Marshal(map[string]string{"key": string(pem)})
ct, hs := "ssh_key", "github.com"
cred := &store.SecureCLIUserCredential{
CredentialType: &ct,
HostScope: &hs,
EncryptedEnv: blob,
}
a := gitAdapter{}
inj, err := a.Prepare(context.Background(), nil, cred,
[]string{"clone", "git@github.com:o/r.git"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if err := inj.Cleanup(); err != nil {
t.Fatalf("first cleanup: %v", err)
}
if err := inj.Cleanup(); err != nil {
t.Fatalf("second cleanup must be nil, got %v", err)
}
}
// 8. Blob missing 'key' field rejected before any tmpfile is created.
func TestGitAdapter_PrepareSSH_RejectsMalformedBlob(t *testing.T) {
ct, hs := "ssh_key", "github.com"
cred := &store.SecureCLIUserCredential{
CredentialType: &ct,
HostScope: &hs,
EncryptedEnv: []byte(`{"private_key":"-----BEGIN..."}`),
}
a := gitAdapter{}
_, err := a.Prepare(context.Background(), nil, cred,
[]string{"clone", "git@github.com:o/r.git"})
if err == nil {
t.Fatal("expected error for missing 'key' field")
}
}
@@ -0,0 +1,417 @@
package tools
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func gitAdapterInstance(t *testing.T) CredentialAdapter {
t.Helper()
a := AdapterFor("git")
if a.Name() != "git" {
t.Fatalf("git adapter not registered; AdapterFor(git)=%q", a.Name())
}
return a
}
// 1. Sniffing — locks AC8
func TestGitAdapter_ShouldInject(t *testing.T) {
a := gitAdapterInstance(t)
yes := []string{"clone", "fetch", "pull", "push", "submodule"}
no := []string{"status", "log", "diff", "commit", "init", "config", "--version", ""}
for _, sub := range yes {
if !a.ShouldInject([]string{sub, "x"}) {
t.Errorf("ShouldInject(%q) = false, want true", sub)
}
}
for _, sub := range no {
if a.ShouldInject([]string{sub}) {
t.Errorf("ShouldInject(%q) = true, want false", sub)
}
}
if a.ShouldInject(nil) {
t.Errorf("ShouldInject(nil) = true, want false")
}
}
// 2. Host parsing
func TestParseHostFromGitURL(t *testing.T) {
tests := []struct {
url string
want string
err bool
}{
{"https://github.com/o/r.git", "github.com", false},
{"https://gitea.example.com:8443/o/r.git", "gitea.example.com:8443", false},
{"git@github.com:o/r.git", "github.com", false},
{"ssh://git@gitlab.com:2222/o/r.git", "gitlab.com:2222", false},
{"ssh://git@gitlab.com/o/r.git", "gitlab.com", false},
{"", "", true},
{"not-a-url", "", true},
{"http://", "", true},
}
for _, tc := range tests {
got, err := parseHostFromGitURL(tc.url)
if tc.err {
if err == nil {
t.Errorf("parseHostFromGitURL(%q) err=nil, want err", tc.url)
}
continue
}
if err != nil {
t.Errorf("parseHostFromGitURL(%q) err=%v", tc.url, err)
continue
}
if got != tc.want {
t.Errorf("parseHostFromGitURL(%q) = %q, want %q", tc.url, got, tc.want)
}
}
}
// 3. Clone host resolution
func TestResolveTargetHost_Clone(t *testing.T) {
host, err := resolveTargetHost(context.Background(), []string{"clone", "--depth=1", "https://github.com/o/r.git"}, "")
if err != nil || host != "github.com" {
t.Fatalf("clone with URL: host=%q err=%v, want github.com nil", host, err)
}
if _, err := resolveTargetHost(context.Background(), []string{"clone", "--depth=1"}, ""); err == nil {
t.Fatalf("clone with no URL: err=nil, want err")
}
}
// 4. fetch/pull/push host resolution via fake git stub
func TestResolveTargetHost_RemoteName(t *testing.T) {
tmp := t.TempDir()
// Fake git script: outputs "https://stub.example.com/o/r.git" for any config --get remote.<x>.url
stub := filepath.Join(tmp, "git")
script := "#!/bin/sh\necho https://stub.example.com/o/r.git\n"
if runtime.GOOS == "windows" {
t.Skip("shell script stub not supported on windows")
}
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
// Prepend tmp to PATH so `git` resolves to our stub.
oldPath := os.Getenv("PATH")
t.Cleanup(func() { os.Setenv("PATH", oldPath) })
os.Setenv("PATH", tmp+string(os.PathListSeparator)+oldPath)
cases := [][]string{
{"fetch"},
{"fetch", "origin"},
{"pull", "upstream"},
{"push", "origin", "main"},
{"push", "--repo=mirror"},
{"submodule", "update", "--init"},
}
for _, argv := range cases {
host, err := resolveTargetHost(context.Background(), argv, tmp)
if err != nil {
t.Errorf("resolveTargetHost(%v) err=%v", argv, err)
continue
}
if host != "stub.example.com" {
t.Errorf("resolveTargetHost(%v) host=%q, want stub.example.com", argv, host)
}
}
}
// 5. Host mismatch — AC3
func TestGitAdapter_HostMismatch(t *testing.T) {
a := gitAdapterInstance(t)
credType := "pat"
hostScope := "github.com"
blob, _ := json.Marshal(map[string]string{"token": "ghp_secrettoken12345"})
cred := &store.SecureCLIUserCredential{
CredentialType: &credType,
HostScope: &hostScope,
EncryptedEnv: blob,
}
_, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, cred,
[]string{"clone", "https://gitlab.com/o/r.git"})
if err == nil {
t.Fatalf("expected host mismatch error")
}
var mm *errCredentialHostMismatch
if !errors.As(err, &mm) {
t.Fatalf("err=%v (%T), want *errCredentialHostMismatch", err, err)
}
if mm.credHost != "github.com" || mm.targetHost != "gitlab.com" {
t.Errorf("mismatch fields: cred=%q target=%q", mm.credHost, mm.targetHost)
}
// Error string must not leak token.
if strings.Contains(err.Error(), "ghp_secrettoken12345") {
t.Errorf("error leaks token: %v", err)
}
}
// 6. PAT injection shape — env approach, no argv
func TestGitAdapter_PreparePAT(t *testing.T) {
a := gitAdapterInstance(t)
credType := "pat"
hostScope := "github.com"
blob, _ := json.Marshal(map[string]string{"token": "ghp_abc"})
cred := &store.SecureCLIUserCredential{
CredentialType: &credType,
HostScope: &hostScope,
EncryptedEnv: blob,
}
inj, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, cred,
[]string{"clone", "https://github.com/o/r.git"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if inj == nil {
t.Fatal("nil Injection")
}
if len(inj.ArgvPrefix) != 0 {
t.Errorf("ArgvPrefix=%v, want empty (PAT goes through env)", inj.ArgvPrefix)
}
wantEnv := map[string]string{
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "http.https://github.com/.extraheader",
"GIT_CONFIG_VALUE_0": "Authorization: Bearer ghp_abc",
}
if !reflect.DeepEqual(inj.Env, wantEnv) {
t.Errorf("Env=%v, want %v", inj.Env, wantEnv)
}
if len(inj.ScrubValues) != 1 || inj.ScrubValues[0] != "ghp_abc" {
t.Errorf("ScrubValues=%v, want [ghp_abc]", inj.ScrubValues)
}
if inj.Cleanup != nil {
t.Errorf("Cleanup must be nil for PAT path")
}
}
// 8a. CRLF rejection
func TestGitAdapter_PreparePAT_RejectsCRLF(t *testing.T) {
a := gitAdapterInstance(t)
credType := "pat"
hostScope := "github.com"
blob, _ := json.Marshal(map[string]string{"token": "ghp_abc\r\nX-Injected: evil"})
cred := &store.SecureCLIUserCredential{
CredentialType: &credType,
HostScope: &hostScope,
EncryptedEnv: blob,
}
_, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, cred,
[]string{"clone", "https://github.com/o/r.git"})
if err == nil {
t.Fatalf("expected CRLF rejection")
}
if !errors.Is(err, errTokenControlChar) {
t.Errorf("err=%v, want errTokenControlChar", err)
}
}
// 8b. Empty + oversize rejection
func TestGitAdapter_PreparePAT_RejectsEmptyAndOversize(t *testing.T) {
a := gitAdapterInstance(t)
credType := "pat"
hostScope := "github.com"
emptyBlob, _ := json.Marshal(map[string]string{"token": ""})
cred := &store.SecureCLIUserCredential{CredentialType: &credType, HostScope: &hostScope, EncryptedEnv: emptyBlob}
if _, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, cred,
[]string{"clone", "https://github.com/o/r.git"}); !errors.Is(err, errEmptyToken) {
t.Errorf("empty token: err=%v, want errEmptyToken", err)
}
bigBlob, _ := json.Marshal(map[string]string{"token": strings.Repeat("a", 5000)})
cred.EncryptedEnv = bigBlob
if _, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, cred,
[]string{"clone", "https://github.com/o/r.git"}); !errors.Is(err, errTokenTooLong) {
t.Errorf("oversize token: err=%v, want errTokenTooLong", err)
}
}
// 8c. IDN normalization
func TestNormalizeHost_IDN(t *testing.T) {
tests := []struct {
in string
want string
}{
{"github.com", "github.com"},
{"GitHub.Com.", "github.com"},
{" GitHub.Com ", "github.com"},
{"gitea.example.com:8443", "gitea.example.com:8443"},
}
for _, tc := range tests {
got, err := normalizeHost(tc.in)
if err != nil {
t.Errorf("normalizeHost(%q) err=%v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("normalizeHost(%q) = %q, want %q", tc.in, got, tc.want)
}
}
// Empty after trim
if _, err := normalizeHost(" "); err == nil {
t.Errorf("normalizeHost empty: err=nil, want err")
}
// IDN punycode round trip — both forms normalize to the same ASCII output.
asciiFromUnicode, err := normalizeHost("gitlab.中国")
if err != nil {
t.Fatalf("normalizeHost unicode: %v", err)
}
asciiFromPuny, err := normalizeHost("gitlab.xn--fiqs8s")
if err != nil {
t.Fatalf("normalizeHost puny: %v", err)
}
if asciiFromUnicode != asciiFromPuny {
t.Errorf("unicode=%q puny=%q — must match after normalize", asciiFromUnicode, asciiFromPuny)
}
}
// 8d. Embedded userinfo rejection
func TestParseHostFromGitURL_RejectsUserinfo(t *testing.T) {
if _, err := parseHostFromGitURL("https://attacker.com@github.com/o/r.git"); err == nil {
t.Errorf("expected error for embedded userinfo")
}
if _, err := parseHostFromGitURL("https://user:pass@github.com/o/r.git"); err == nil {
t.Errorf("expected error for userinfo with password")
}
// scp-form git@host:path is NOT userinfo (no '@' arrives as URL userinfo); allowed.
if _, err := parseHostFromGitURL("git@github.com:o/r.git"); err != nil {
t.Errorf("scp-form should parse: %v", err)
}
// ssh:// with git@ is the conventional form — allow because it's the only
// supported form for SSH and removing it would break Phase 4.
if _, err := parseHostFromGitURL("ssh://git@gitlab.com/o/r.git"); err != nil {
t.Errorf("ssh://git@host should parse: %v", err)
}
}
// 9. Legacy env passthrough
func TestGitAdapter_LegacyEnvCredentialPassthrough(t *testing.T) {
a := gitAdapterInstance(t)
// credential_type=NULL → passthrough
cred := &store.SecureCLIUserCredential{
EncryptedEnv: []byte(`{"GIT_TOKEN":"x"}`),
}
inj, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, cred,
[]string{"clone", "https://github.com/o/r.git"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if len(inj.Env) != 0 || len(inj.ArgvPrefix) != 0 {
t.Errorf("legacy env cred must passthrough, got %+v", inj)
}
// Explicit credential_type="env" → same.
envType := "env"
cred.CredentialType = &envType
inj, err = a.Prepare(context.Background(), &store.SecureCLIBinary{}, cred,
[]string{"clone", "https://github.com/o/r.git"})
if err != nil {
t.Fatalf("Prepare env: %v", err)
}
if len(inj.Env) != 0 {
t.Errorf("env cred must passthrough, got %+v", inj)
}
}
// (Phase 4 implements ssh_key; coverage moved to
// credential_adapter_git_ssh_test.go.)
// 13. CVE-2018-17456 regression: malicious .git/config must not execute
// `ext::sh -c …` payload. Our `git config --get` path returns the literal
// URL string for parseHostFromGitURL to reject, NEVER hands it to
// `git remote get-url` (which would evaluate the protocol handler).
func TestResolveTargetHost_RejectsExtProtocolInjection(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses /tmp marker; POSIX only")
}
tmp := t.TempDir()
gitDir := filepath.Join(tmp, ".git")
if err := os.MkdirAll(gitDir, 0o755); err != nil {
t.Fatal(err)
}
// Init a minimal repo so `git -C <tmp>` recognizes it.
if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
t.Fatal(err)
}
marker := filepath.Join(tmp, "pwn-marker")
// Plant malicious config — would execute `touch <marker>` if URL ever passes
// through git's protocol handler.
cfg := `[core]
repositoryformatversion = 0
[remote "origin"]
url = ext::sh -c "touch ` + marker + `"
`
if err := os.WriteFile(filepath.Join(gitDir, "config"), []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
_, err := resolveTargetHost(context.Background(), []string{"fetch"}, tmp)
// We expect either an error or a non-host string that parseHostFromGitURL
// rejects. EITHER way the marker file must NOT exist.
_ = err
if _, statErr := os.Stat(marker); statErr == nil {
t.Fatalf("CVE-2018-17456 regression: ext::sh payload executed — marker %s exists", marker)
}
}
// Sanity: ensure the test stub of `git --version` is real (avoids accidental
// stub bleed from the RemoteName test affecting the CVE test).
func TestGit_ConfigGet_UsesRealGit(t *testing.T) {
out, err := exec.Command("git", "--version").CombinedOutput()
if err != nil || !strings.Contains(string(out), "git version") {
t.Skipf("real git unavailable: out=%q err=%v", out, err)
}
}
// 14. DenyArgs in the `git` preset must block `-c http.…` overrides that
// would shadow the adapter's host-scoped extraheader.
func TestSecureCLI_DenyArgs_BlocksHttpCConfig(t *testing.T) {
preset, ok := CLIPresets["git"]
if !ok {
t.Fatal("git preset missing")
}
denyJSON, err := json.Marshal(preset.DenyArgs)
if err != nil {
t.Fatal(err)
}
mustBlock := [][]string{
{"-c", "http.extraHeader=Authorization: Bearer evil", "clone", "https://github.com/o/r.git"},
{"-c", "credential.helper=store", "clone", "https://github.com/o/r.git"},
{"-c", "core.sshCommand=ssh -i /etc/shadow", "clone", "git@github.com:o/r.git"},
{"config", "--global", "user.email", "x@y"},
{"config", "--system", "credential.helper", "store"},
{"daemon", "--export-all"},
}
for _, args := range mustBlock {
if p := matchesBinaryDeny(args, denyJSON); p == "" {
t.Errorf("DenyArgs did NOT block %v", args)
}
}
mustAllow := [][]string{
{"clone", "https://github.com/o/r.git"},
{"fetch", "origin"},
{"push", "origin", "main"},
{"submodule", "update", "--init", "--recursive"},
{"status"},
{"config", "user.email"}, // local-only config read; not --global/--system
}
for _, args := range mustAllow {
if p := matchesBinaryDeny(args, denyJSON); p != "" {
t.Errorf("DenyArgs falsely blocked %v with pattern %q", args, p)
}
}
}
// sortedHelperForTest is only here to keep `sort` import used.
var _ = sort.Strings
+101
View File
@@ -0,0 +1,101 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// psqlAdapter is the framework-validation stub adapter (Phase 2b).
//
// Why it exists: Phase 2 introduces a generic CredentialAdapter interface that
// MUST hold for non-git credential families (kubectl, docker, psql, npm…) or
// it's a git hack pretending to be generic. The psql/PGPASSFILE pattern is the
// simplest non-git case — env-only, file-based, no argv mutation — so if the
// interface doesn't fit it cleanly, that's the signal to reshape NOW, before
// Phase 3/4 cement the shape around git.
//
// Production usage requires UI work (Phase 5 extends the cred-type picker
// beyond git). Until then, this preset is registered but only routes when an
// operator manually sets a binary row's adapter_name='psql' AND seeds a
// `pg_password_file` credential via DB / API.
type psqlAdapter struct{}
func (psqlAdapter) Name() string { return "psql" }
// ShouldInject returns true for any invocation — psql has no subcommand surface,
// every call is "the command", so the adapter is always consulted.
func (psqlAdapter) ShouldInject(_ []string) bool { return true }
// pgPasswordFile is the on-disk JSON shape stored in
// secure_cli_user_credentials.encrypted_env when credential_type='pg_password_file'.
type pgPasswordFile struct {
Host string `json:"host"`
Port string `json:"port"`
Database string `json:"database"`
User string `json:"user"`
Password string `json:"password"`
}
func (psqlAdapter) Prepare(_ context.Context, _ *store.SecureCLIBinary, cred *store.SecureCLIUserCredential, _ []string) (*Injection, error) {
if cred == nil {
return &Injection{}, nil
}
credType := ""
if cred.CredentialType != nil {
credType = *cred.CredentialType
}
// Legacy env-vars credential — leave untouched, behave like passthrough.
if credType != "pg_password_file" {
return &Injection{}, nil
}
var pg pgPasswordFile
if err := json.Unmarshal(cred.EncryptedEnv, &pg); err != nil {
return nil, fmt.Errorf("decode pg credential: %w", err)
}
if pg.Password == "" {
return nil, fmt.Errorf("pg credential missing password")
}
line := fmt.Sprintf("%s:%s:%s:%s:%s\n",
escapePgpass(pg.Host),
escapePgpass(pg.Port),
escapePgpass(pg.Database),
escapePgpass(pg.User),
escapePgpass(pg.Password),
)
path, cleanup, err := materializeEphemeral(context.TODO(), []byte(line), "pgpass")
if err != nil {
return nil, err
}
return &Injection{
Env: map[string]string{"PGPASSFILE": path},
Cleanup: cleanup,
// Scrub both the secret AND the on-disk tmpfile path — psql echoes
// `could not open password file "<path>"` on permission/IO errors.
// Mirrors git SSH adapter pattern (credential_adapter_git.go).
ScrubValues: []string{pg.Password, path},
}, nil
}
// escapePgpass escapes backslash and colon per the libpq .pgpass spec so a
// malicious password containing `:` cannot break the line format and inject a
// second entry.
//
// postgresql.org/docs/current/libpq-pgpass.html
func escapePgpass(s string) string {
// Order matters: escape backslash FIRST, then colon, otherwise the colon
// escape's own backslash gets double-escaped.
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `:`, `\:`)
return s
}
func init() {
RegisterAdapter(psqlAdapter{})
}
@@ -0,0 +1,188 @@
package tools
import (
"context"
"encoding/json"
"errors"
"io/fs"
"os"
"runtime"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func psqlAdapterInstance(t *testing.T) CredentialAdapter {
t.Helper()
a := AdapterFor("psql")
if a.Name() != "psql" {
t.Fatalf("psql adapter not registered; AdapterFor(psql)=%q", a.Name())
}
return a
}
func TestPsqlAdapter_RegisteredAndShouldInject(t *testing.T) {
a := psqlAdapterInstance(t)
if !a.ShouldInject(nil) || !a.ShouldInject([]string{"-c", "select 1"}) {
t.Fatalf("psql ShouldInject must always return true")
}
}
func TestPsqlAdapter_LegacyEnvPassthrough(t *testing.T) {
a := psqlAdapterInstance(t)
// No credential → passthrough Injection, no error.
inj, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, nil, nil)
if err != nil || inj == nil || len(inj.Env) != 0 {
t.Fatalf("nil cred should be no-op: inj=%+v err=%v", inj, err)
}
// Cred with credential_type=NULL (legacy env-vars) → passthrough.
inj, err = a.Prepare(context.Background(), &store.SecureCLIBinary{}, &store.SecureCLIUserCredential{
EncryptedEnv: []byte(`{"PGPASSWORD":"x"}`),
}, nil)
if err != nil {
t.Fatalf("legacy env cred raised err: %v", err)
}
if inj == nil || len(inj.Env) != 0 || inj.Cleanup != nil {
t.Fatalf("legacy env cred must produce empty Injection, got %+v", inj)
}
}
func TestPsqlAdapter_PreparePgPasswordFile(t *testing.T) {
a := psqlAdapterInstance(t)
credType := "pg_password_file"
blob, _ := json.Marshal(pgPasswordFile{
Host: "db.internal",
Port: "5432",
Database: "prod",
User: "app",
Password: "s3cret!",
})
inj, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, &store.SecureCLIUserCredential{
CredentialType: &credType,
EncryptedEnv: blob,
}, nil)
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if inj == nil {
t.Fatalf("Prepare returned nil Injection")
}
t.Cleanup(func() {
if inj.Cleanup != nil {
_ = inj.Cleanup()
}
})
path, ok := inj.Env["PGPASSFILE"]
if !ok || path == "" {
t.Fatalf("PGPASSFILE missing from Injection.Env: %v", inj.Env)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
want := "db.internal:5432:prod:app:s3cret!\n"
if string(got) != want {
t.Fatalf(".pgpass line=%q, want %q", got, want)
}
if runtime.GOOS != "windows" {
info, _ := os.Stat(path)
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("file mode=%o, want 0600", perm)
}
}
// ScrubValues must include both the password AND the on-disk tmpfile path
// (psql echoes `could not open password file "<path>"` on IO errors).
if len(inj.ScrubValues) != 2 || inj.ScrubValues[0] != "s3cret!" || inj.ScrubValues[1] != path {
t.Fatalf("ScrubValues=%v, want [s3cret! %s]", inj.ScrubValues, path)
}
if inj.Cleanup == nil {
t.Fatalf("Cleanup missing")
}
if err := inj.Cleanup(); err != nil {
t.Fatalf("Cleanup: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("file still exists after cleanup")
}
// argv prefix must be empty — psql uses env only.
if len(inj.ArgvPrefix) != 0 {
t.Fatalf("psql adapter must not set ArgvPrefix, got %v", inj.ArgvPrefix)
}
}
func TestPsqlAdapter_EscapesColonsAndBackslashes(t *testing.T) {
a := psqlAdapterInstance(t)
credType := "pg_password_file"
// Adversarial password: contains both : and \.
// .pgpass spec: escape backslash first, then colon.
// p: ab:cd → ab\:cd
// p: x\y → x\\y
// combined: x\:y → x\\\:y
blob, _ := json.Marshal(pgPasswordFile{
Host: "h",
Port: "5432",
Database: "d",
User: "u",
Password: `x\:y`,
})
inj, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, &store.SecureCLIUserCredential{
CredentialType: &credType,
EncryptedEnv: blob,
}, nil)
if err != nil {
t.Fatalf("Prepare: %v", err)
}
t.Cleanup(func() {
if inj.Cleanup != nil {
_ = inj.Cleanup()
}
})
got, err := os.ReadFile(inj.Env["PGPASSFILE"])
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
want := `h:5432:d:u:x\\\:y` + "\n"
if string(got) != want {
t.Fatalf("escaped line=%q, want %q", got, want)
}
}
func TestPsqlAdapter_RejectsEmptyPassword(t *testing.T) {
a := psqlAdapterInstance(t)
credType := "pg_password_file"
blob, _ := json.Marshal(pgPasswordFile{Host: "h", Port: "5432", Database: "d", User: "u"})
if _, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, &store.SecureCLIUserCredential{
CredentialType: &credType,
EncryptedEnv: blob,
}, nil); err == nil {
t.Fatalf("expected error for empty password")
}
}
func TestPsqlAdapter_RejectsInvalidJSON(t *testing.T) {
a := psqlAdapterInstance(t)
credType := "pg_password_file"
if _, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, &store.SecureCLIUserCredential{
CredentialType: &credType,
EncryptedEnv: []byte(`not json`),
}, nil); err == nil {
t.Fatalf("expected error for invalid JSON")
}
}
// Interface validation gate (Phase 2b §"Interface validation gate"):
// Confirms psql consumed the Phase 2 Injection shape without modification.
// If this test fails to compile after a Phase 2 interface change, the change
// likely needs to be re-evaluated for non-git generality.
func TestPsqlAdapter_InterfaceValidationGate(t *testing.T) {
var _ CredentialAdapter = psqlAdapter{}
// All four Injection fields are exercised by Prepare:
// Env → PGPASSFILE
// Cleanup → tmpfile removal
// ScrubValues → password redaction
// ArgvPrefix → intentionally empty (not all adapters need argv mutation)
}
+254
View File
@@ -0,0 +1,254 @@
package tools
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestPassthroughAdapter_NoOp(t *testing.T) {
a := AdapterFor("passthrough")
if a.Name() != "passthrough" {
t.Fatalf("Name()=%q, want passthrough", a.Name())
}
cases := [][]string{
nil,
{},
{"clone", "https://github.com/x/y.git"},
{"--help"},
}
for _, argv := range cases {
if a.ShouldInject(argv) {
t.Fatalf("ShouldInject(%v)=true, want false (passthrough must never inject)", argv)
}
}
inj, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, nil, []string{"clone"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if inj == nil {
t.Fatalf("Prepare returned nil Injection")
}
if len(inj.ArgvPrefix) != 0 || len(inj.Env) != 0 || len(inj.ScrubValues) != 0 {
t.Fatalf("passthrough produced non-empty Injection: %+v", inj)
}
if inj.Cleanup != nil {
t.Fatalf("passthrough must not register Cleanup")
}
}
func TestAdapterFor_FallsBackToPassthrough(t *testing.T) {
cases := []string{"", "nonexistent", "git-not-registered-yet"}
for _, name := range cases {
a := AdapterFor(name)
if a.Name() != "passthrough" {
t.Fatalf("AdapterFor(%q)=%q, want passthrough", name, a.Name())
}
}
}
// recordingAdapter captures Prepare calls so the hook-point test can verify
// the runtime wired the adapter correctly.
type recordingAdapter struct {
name string
shouldFn func([]string) bool
prepareErr error
calls int32
gotArgv []string
}
func (r *recordingAdapter) Name() string { return r.name }
func (r *recordingAdapter) ShouldInject(argv []string) bool {
if r.shouldFn != nil {
return r.shouldFn(argv)
}
return true
}
func (r *recordingAdapter) Prepare(_ context.Context, _ *store.SecureCLIBinary, _ *store.SecureCLIUserCredential, argv []string) (*Injection, error) {
atomic.AddInt32(&r.calls, 1)
r.gotArgv = append([]string(nil), argv...)
if r.prepareErr != nil {
return nil, r.prepareErr
}
return &Injection{}, nil
}
func TestRegisterAdapter_RoundTrip(t *testing.T) {
name := "test-roundtrip-adapter"
t.Cleanup(func() {
adaptersMu.Lock()
delete(adapters, name)
adaptersMu.Unlock()
})
r := &recordingAdapter{name: name}
RegisterAdapter(r)
got := AdapterFor(name)
if got.Name() != name {
t.Fatalf("AdapterFor(%q)=%q, want %q", name, got.Name(), name)
}
}
func TestRegisterAdapter_NilIgnored(t *testing.T) {
// Should not panic, should not affect registry.
before := AdapterFor("passthrough")
RegisterAdapter(nil)
after := AdapterFor("passthrough")
if before.Name() != after.Name() {
t.Fatalf("nil RegisterAdapter changed passthrough resolution")
}
}
func TestInjection_StructShape(t *testing.T) {
// Locks Injection field types so future refactors can't silently change
// the contract the adapter pipeline depends on.
flag := false
inj := Injection{
ArgvPrefix: []string{"-c", "foo=bar"},
Env: map[string]string{"GIT_TERMINAL_PROMPT": "0"},
Cleanup: func() error { flag = true; return nil },
ScrubValues: []string{"secretvalue"},
}
if inj.ArgvPrefix[0] != "-c" || inj.ArgvPrefix[1] != "foo=bar" {
t.Fatalf("ArgvPrefix not preserved")
}
if inj.Env["GIT_TERMINAL_PROMPT"] != "0" {
t.Fatalf("Env not preserved")
}
if err := inj.Cleanup(); err != nil || !flag {
t.Fatalf("Cleanup did not fire: err=%v flag=%v", err, flag)
}
if inj.ScrubValues[0] != "secretvalue" {
t.Fatalf("ScrubValues not preserved")
}
}
func TestHashHostScope(t *testing.T) {
if got := hashHostScope(nil); got != "none" {
t.Fatalf("hashHostScope(nil)=%q, want none", got)
}
empty := ""
if got := hashHostScope(&empty); got != "none" {
t.Fatalf("hashHostScope(&\"\")=%q, want none", got)
}
a := "github.com"
b := "gitlab.com"
ha1 := hashHostScope(&a)
ha2 := hashHostScope(&a)
hb := hashHostScope(&b)
if ha1 != ha2 {
t.Fatalf("same input produced different hashes: %q vs %q", ha1, ha2)
}
if ha1 == hb {
t.Fatalf("different inputs collided: %q == %q", ha1, hb)
}
if len(ha1) != 8 {
t.Fatalf("hash length=%d, want 8 hex chars", len(ha1))
}
// Must not contain the plaintext hostname anywhere.
if ha1 == a {
t.Fatalf("hash leaked plaintext hostname")
}
}
func TestSortedKeys_Deterministic(t *testing.T) {
in := map[string]string{
"GIT_SSH_COMMAND": "ssh -i /tmp/x",
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_COUNT": "1",
}
got := sortedKeys(in)
want := []string{"GIT_CONFIG_COUNT", "GIT_SSH_COMMAND", "GIT_TERMINAL_PROMPT"}
if len(got) != len(want) {
t.Fatalf("sortedKeys len=%d, want %d", len(got), len(want))
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("sortedKeys[%d]=%q, want %q", i, got[i], want[i])
}
}
if sortedKeys(nil) != nil {
t.Fatalf("sortedKeys(nil) must return nil")
}
if sortedKeys(map[string]string{}) != nil {
t.Fatalf("sortedKeys(empty map) must return nil")
}
}
func TestScrubBag_PerRequestIsolation(t *testing.T) {
// Two parallel contexts must not see each other's scrub values.
ctxA := WithScrubBag(context.Background())
ctxB := WithScrubBag(context.Background())
AddScrubValuesCtx(ctxA, "secretAAAAAA")
AddScrubValuesCtx(ctxB, "secretBBBBBB")
gotA := ScrubCredentialsCtx(ctxA, "value: secretAAAAAA leaked + secretBBBBBB visible")
gotB := ScrubCredentialsCtx(ctxB, "value: secretAAAAAA visible + secretBBBBBB leaked")
// A's bag redacts A's secret, leaves B's untouched.
if got := gotA; got != "value: [REDACTED] leaked + secretBBBBBB visible" {
t.Fatalf("ctxA scrub leaked across tenants: %q", got)
}
if got := gotB; got != "value: secretAAAAAA visible + [REDACTED] leaked" {
t.Fatalf("ctxB scrub leaked across tenants: %q", got)
}
}
func TestScrubBag_NoBagInContextIsNoop(t *testing.T) {
// AddScrubValuesCtx without a bag must not panic.
AddScrubValuesCtx(context.Background(), "should-not-crash")
// ScrubCredentialsCtx without a bag still runs regex pass.
got := ScrubCredentialsCtx(context.Background(), "api_key=ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
if got == "api_key=ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
t.Fatalf("regex pass should still scrub even without bag, got %q", got)
}
}
func TestScrubBag_ConcurrentAdds(t *testing.T) {
// Race-detector smoke test for the per-bag mutex.
ctx := WithScrubBag(context.Background())
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
AddScrubValuesCtx(ctx, "concurrentvalueXXXXXX")
}(i)
}
wg.Wait()
out := ScrubCredentialsCtx(ctx, "echo concurrentvalueXXXXXX done")
if out != "echo [REDACTED] done" {
t.Fatalf("concurrent bag adds failed: %q", out)
}
}
func TestScrubBag_ShortValuesIgnored(t *testing.T) {
ctx := WithScrubBag(context.Background())
AddScrubValuesCtx(ctx, "x", "ab", "abc", "abcde") // all < 6 chars
got := ScrubCredentialsCtx(ctx, "abc abcde x ab")
if got != "abc abcde x ab" {
t.Fatalf("short values were redacted (false positive): %q", got)
}
}
// recordingAdapter is the kind of probe a future hook-point integration test
// would register. The compile-time assertion here keeps the interface stable.
func TestRecordingAdapter_ImplementsInterface(t *testing.T) {
var _ CredentialAdapter = (*recordingAdapter)(nil)
// Sanity: an adapter returning prepareErr does not panic.
r := &recordingAdapter{name: "boom", prepareErr: errors.New("forced")}
if _, err := r.Prepare(context.Background(), nil, nil, []string{"x"}); err == nil {
t.Fatalf("Prepare did not return forced error")
}
}
+183
View File
@@ -0,0 +1,183 @@
// Phase 6 — audit log shape tests for `security.system_env_injection`.
//
// Pins the JSON field schema operators grep for in their SIEM. A change here
// breaks log-search dashboards in production, so the test asserts:
// - exact event name (`msg=security.system_env_injection`)
// - presence of every documented field
// - host scope hashed (not plaintext) — PII safety
// - env keys present, env values NOT present
// - PAT/SSH key bytes NOT present anywhere in the captured buffer
package tools
import (
"bytes"
"encoding/json"
"log/slog"
"regexp"
"strings"
"testing"
)
// capturedAudit runs fn with a JSON slog handler capturing into buf, then
// restores the previous default logger. Returns parsed JSON records.
func capturedAudit(t *testing.T, fn func()) ([]map[string]any, string) {
t.Helper()
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(prev)
fn()
raw := buf.String()
var records []map[string]any
for line := range strings.SplitSeq(strings.TrimRight(raw, "\n"), "\n") {
if line == "" {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("log line not JSON: %q (%v)", line, err)
}
records = append(records, rec)
}
return records, raw
}
// 1. Audit log shape — PAT path emits the documented field schema.
func TestEmitSystemEnvInjectionAudit_PAT(t *testing.T) {
scope := "github.com"
// Use sentinel values long enough that substring match in the captured
// log buffer is meaningful (short values like "1" would false-match digits
// in the timestamp).
patToken := "ghp_SENTINEL_PAT_VALUE_4242424242424242"
inj := &Injection{
Env: map[string]string{
"GIT_CONFIG_COUNT": "SENTINEL_COUNT_VALUE",
"GIT_CONFIG_KEY_0": "http.https://github.com/.extraheader",
"GIT_CONFIG_VALUE_0": "AUTHORIZATION: basic " + patToken,
},
// PAT path uses env only, no argv mutation.
ArgvPrefix: nil,
}
records, raw := capturedAudit(t, func() {
emitSystemEnvInjectionAudit("git", "git", "user-42", inj, &scope)
})
if len(records) != 1 {
t.Fatalf("expected exactly 1 audit record, got %d (raw=%q)", len(records), raw)
}
rec := records[0]
if rec["msg"] != "security.system_env_injection" {
t.Fatalf("msg=%v, want security.system_env_injection", rec["msg"])
}
if rec["adapter"] != "git" {
t.Errorf("adapter=%v, want git", rec["adapter"])
}
if rec["binary"] != "git" {
t.Errorf("binary=%v, want git", rec["binary"])
}
if rec["user_id"] != "user-42" {
t.Errorf("user_id=%v, want user-42", rec["user_id"])
}
if rec["argv_prefix_len"] != float64(0) {
t.Errorf("argv_prefix_len=%v, want 0", rec["argv_prefix_len"])
}
keys, _ := rec["env_keys"].([]any)
want := []string{"GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"}
if len(keys) != len(want) {
t.Fatalf("env_keys=%v, want %v", keys, want)
}
for i, k := range want {
if keys[i] != k {
t.Errorf("env_keys[%d]=%v, want %s (must be sorted)", i, keys[i], k)
}
}
hash, _ := rec["host_scope_hash"].(string)
if !regexp.MustCompile(`^[0-9a-f]{8}$`).MatchString(hash) {
t.Errorf("host_scope_hash=%q, want 8 hex chars", hash)
}
if hash == scope {
t.Errorf("host_scope_hash leaked plaintext hostname")
}
// Plaintext hostname must NOT appear anywhere in the raw log output.
if strings.Contains(raw, scope) {
t.Errorf("plaintext hostname %q leaked into audit log: %s", scope, raw)
}
// Env VALUES must NOT appear in the audit log (only NAMES go in env_keys).
// Especially the PAT-like content: AC6 redaction guarantee.
for _, v := range inj.Env {
if strings.Contains(raw, v) {
t.Errorf("env value %q leaked into audit log: %s", v, raw)
}
}
if strings.Contains(raw, patToken) {
t.Errorf("PAT token leaked into audit log: %s", raw)
}
}
// 2. Audit log shape — SSH path: env_keys=[GIT_SSH_COMMAND], hash present,
// no PEM bytes in the log.
func TestEmitSystemEnvInjectionAudit_SSH(t *testing.T) {
scope := "gitlab.example.com:2222"
pemBody := "-----BEGIN OPENSSH PRIVATE KEY-----\nfakebody\n-----END OPENSSH PRIVATE KEY-----"
inj := &Injection{
Env: map[string]string{
"GIT_SSH_COMMAND": "ssh -i /tmp/goclaw-gitkey-xyz -o StrictHostKeyChecking=accept-new",
},
}
records, raw := capturedAudit(t, func() {
emitSystemEnvInjectionAudit("git", "git", "u1", inj, &scope)
})
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
rec := records[0]
keys, _ := rec["env_keys"].([]any)
if len(keys) != 1 || keys[0] != "GIT_SSH_COMMAND" {
t.Errorf("env_keys=%v, want [GIT_SSH_COMMAND]", keys)
}
hash, _ := rec["host_scope_hash"].(string)
if !regexp.MustCompile(`^[0-9a-f]{8}$`).MatchString(hash) {
t.Errorf("host_scope_hash=%q, want 8 hex chars", hash)
}
// PEM contents and plaintext hostname must NOT leak.
if strings.Contains(raw, "BEGIN OPENSSH") || strings.Contains(raw, pemBody) {
t.Errorf("PEM content leaked into audit log: %s", raw)
}
if strings.Contains(raw, "gitlab.example.com") {
t.Errorf("plaintext hostname leaked into audit log: %s", raw)
}
}
// 3. Nil injection: emitter is a no-op (audit only fires on actual injection).
func TestEmitSystemEnvInjectionAudit_NilInjection(t *testing.T) {
records, _ := capturedAudit(t, func() {
emitSystemEnvInjectionAudit("git", "git", "u1", nil, nil)
})
if len(records) != 0 {
t.Fatalf("expected 0 records for nil injection, got %d", len(records))
}
}
// 4. Nil host scope (passthrough adapter): hash = "none", not crashy.
func TestEmitSystemEnvInjectionAudit_NilHostScope(t *testing.T) {
inj := &Injection{Env: map[string]string{"FOO": "bar"}}
records, _ := capturedAudit(t, func() {
emitSystemEnvInjectionAudit("passthrough", "gh", "u1", inj, nil)
})
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
if records[0]["host_scope_hash"] != "none" {
t.Errorf("host_scope_hash=%v, want none", records[0]["host_scope_hash"])
}
}
+17
View File
@@ -31,6 +31,7 @@ func GenerateCredentialContext(creds []store.SecureCLIBinary) string {
b.WriteString("- Parse JSON output directly — do NOT pipe to jq\n\n")
b.WriteString("### Available CLIs:\n\n")
hasGit := false
for _, c := range creds {
b.WriteString(fmt.Sprintf("**%s** — %s\n", c.BinaryName, c.Description))
if blocked := summarizeDenyPatterns(c.DenyArgs); blocked != "" {
@@ -40,6 +41,22 @@ func GenerateCredentialContext(creds []store.SecureCLIBinary) string {
b.WriteString(fmt.Sprintf(" Tip: %s\n", c.Tips))
}
b.WriteString("\n")
if c.AdapterName != nil && *c.AdapterName == "git" {
hasGit = true
}
}
if hasGit {
// Git adapter has fixed, predictable semantics worth surfacing to the
// LLM up front. Keeps the agent from attempting workarounds (writing
// to ~/.gitconfig, exporting GIT_USERNAME, etc.) when a subcommand is
// outside the auto-auth set.
b.WriteString("### git (adapter-managed):\n")
b.WriteString("- Auto-authenticated subcommands: `clone`, `fetch`, `pull`, `push`, `submodule`.\n")
b.WriteString("- Other subcommands (status, log, diff, commit, branch, …) run WITHOUT credentials — safe for read-only repo work.\n")
b.WriteString("- `git config --global` is denied by policy.\n")
b.WriteString("- Auth is host-scoped: a credential for `github.com` will NOT authenticate to `gitlab.com`.\n")
b.WriteString("- Do NOT attempt to print, copy, or modify the credential — it is injected per-process only.\n\n")
}
b.WriteString("### When a credentialed CLI command is blocked:\n")
+40
View File
@@ -38,3 +38,43 @@ func TestGenerateCredentialContext_BlockedSectionScopedToMarker(t *testing.T) {
t.Errorf("output still contains unqualified wording %q.\nOutput:\n%s", dontWant, out)
}
}
// 14. Phase 5: when the git adapter is enabled on at least one preset, the
// generated TOOLS.md supplement includes the git-adapter usage block so the
// LLM knows which subcommands auto-authenticate and which run un-credentialed.
func TestGenerateCredentialContext_IncludesGit(t *testing.T) {
git := "git"
creds := []store.SecureCLIBinary{{
BinaryName: "git",
Description: "git VCS",
AdapterName: &git,
}}
out := GenerateCredentialContext(creds)
wantContains := []string{
"### git (adapter-managed):",
"Auto-authenticated subcommands",
"`clone`, `fetch`, `pull`, `push`, `submodule`",
"host-scoped",
"git config --global",
}
for _, s := range wantContains {
if !strings.Contains(out, s) {
t.Errorf("expected output to contain %q, but it did not.\nOutput:\n%s", s, out)
}
}
}
// When NO preset has adapter_name=="git", the git block must NOT appear —
// keeps TOOLS.md context minimal when only passthrough binaries are wired.
func TestGenerateCredentialContext_OmitsGitWhenAbsent(t *testing.T) {
creds := []store.SecureCLIBinary{{
BinaryName: "gh",
Description: "GitHub CLI",
}}
out := GenerateCredentialContext(creds)
if strings.Contains(out, "### git (adapter-managed):") {
t.Errorf("git block leaked into non-git context.\nOutput:\n%s", out)
}
}
+64
View File
@@ -0,0 +1,64 @@
package tools
import (
"context"
"fmt"
"os"
"sync/atomic"
)
// materializeEphemeral writes content to a per-call 0600 tmpfile and returns a
// cleanup closure that removes it idempotently. The path is safe to pass via
// env vars (PGPASSFILE, KUBECONFIG, GIT_SSH_COMMAND's -i flag) but NOT via
// argv — argv is world-readable on Linux via /proc/<pid>/cmdline.
//
// Memfd is intentionally NOT used. /proc/self/fd/N resolves "self" against the
// calling process; for child processes (and grandchildren like git→ssh) the
// kernel resolves against the child, which sees EBADF unless the parent passed
// the fd via ExecCommand.ExtraFiles AND the child binary actually reads from
// that fd number. git/psql/docker/kubectl have no API to forward fds to their
// subprocesses, so tmpfile + defer remove is the safe, portable default.
//
// Callers MUST `defer cleanup()` — the helper alone is not enough; if the
// caller goroutine panics without firing the defer, the tmpfile leaks until
// reboot (or whatever cleans os.TempDir on the host).
//
// On Windows the explicit Chmod is a no-op (POSIX-only modes). ACL hardening
// is deferred to v2 with a documented limitation.
func materializeEphemeral(_ context.Context, content []byte, prefix string) (string, func() error, error) {
f, err := os.CreateTemp("", "goclaw-"+prefix+"-*")
if err != nil {
return "", nil, fmt.Errorf("create ephemeral file: %w", err)
}
name := f.Name()
// Idempotent cleanup latch — concurrent callers don't double-remove.
var done atomic.Bool
cleanup := func() error {
if done.Swap(true) {
return nil
}
if err := os.Remove(name); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// Re-chmod 0600 even though CreateTemp already does this on POSIX —
// explicit + future-proofs if the runtime ever changes the default.
if err := f.Chmod(0o600); err != nil {
_ = f.Close()
_ = cleanup()
return "", nil, fmt.Errorf("chmod ephemeral file: %w", err)
}
if _, err := f.Write(content); err != nil {
_ = f.Close()
_ = cleanup()
return "", nil, fmt.Errorf("write ephemeral file: %w", err)
}
if err := f.Close(); err != nil {
_ = cleanup()
return "", nil, fmt.Errorf("close ephemeral file: %w", err)
}
return name, cleanup, nil
}
+107
View File
@@ -0,0 +1,107 @@
package tools
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"testing"
)
func TestMaterializeEphemeral_WritesAndCleansUp(t *testing.T) {
want := []byte("ephemeral-payload\n")
path, cleanup, err := materializeEphemeral(context.Background(), want, "test")
if err != nil {
t.Fatalf("materializeEphemeral: %v", err)
}
// Path must live under os.TempDir() (per-user on POSIX, %TEMP% on Windows).
if dir := filepath.Dir(path); dir != filepath.Clean(os.TempDir()) {
t.Fatalf("ephemeral parent dir=%q, want %q", dir, filepath.Clean(os.TempDir()))
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != string(want) {
t.Fatalf("content=%q, want %q", got, want)
}
// POSIX-only: mode 0600. On Windows the mode bits do not reflect ACLs;
// the explicit Chmod is a documented no-op.
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("file mode=%o, want 0600", perm)
}
}
if err := cleanup(); err != nil {
t.Fatalf("cleanup: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("file still exists after cleanup: stat err=%v", err)
}
// Second cleanup is a no-op, not an error.
if err := cleanup(); err != nil {
t.Fatalf("idempotent cleanup returned err: %v", err)
}
}
func TestMaterializeEphemeral_ConcurrentCleanup(t *testing.T) {
path, cleanup, err := materializeEphemeral(context.Background(), []byte("x"), "concurrent")
if err != nil {
t.Fatalf("materializeEphemeral: %v", err)
}
var nilCount, errCount atomic.Int32
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := cleanup(); err == nil {
nilCount.Add(1)
} else {
errCount.Add(1)
}
}()
}
wg.Wait()
// All callers see nil (idempotent latch swallows redundant deletes).
if got := nilCount.Load(); got != 64 {
t.Fatalf("concurrent cleanup nil count=%d, want 64", got)
}
if got := errCount.Load(); got != 0 {
t.Fatalf("concurrent cleanup err count=%d, want 0", got)
}
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("file still exists after concurrent cleanup")
}
}
func TestMaterializeEphemeral_ZeroContentOK(t *testing.T) {
path, cleanup, err := materializeEphemeral(context.Background(), nil, "empty")
if err != nil {
t.Fatalf("materializeEphemeral: %v", err)
}
t.Cleanup(func() { _ = cleanup() })
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if info.Size() != 0 {
t.Fatalf("size=%d, want 0", info.Size())
}
}
+45
View File
@@ -13,6 +13,12 @@ type CLIPreset struct {
DenyVerbose []string `json:"deny_verbose"`
Timeout int `json:"timeout"`
Tips string `json:"tips"`
// AdapterName defaults the binary row's adapter_name column at create
// time. Empty (default) → passthrough adapter (legacy env injection).
// Set to e.g. "git" by typed-credential presets (Phase 3+). Runtime
// adapter lookup reads the DB column, not this field — so operator
// overrides post-create take precedence.
AdapterName string `json:"adapter_name,omitempty"`
}
// EnvVarDef describes an environment variable required by a CLI tool.
@@ -94,6 +100,45 @@ var CLIPresets = map[string]CLIPreset{
Timeout: 300,
Tips: "Use -json flag for structured output",
},
"git": {
BinaryName: "git",
Description: "Git with credential adapter (PAT or SSH host-scoped credentials managed by goclaw)",
// Credential storage is adapter-managed (encrypted_env carries the
// typed blob), not env-paste. Keep EnvVars empty so the UI doesn't
// offer a free-text PAT field that would land in plain env.
EnvVars: nil,
// Deny patterns block the agent from:
// - persisting tokens via `git config --global/--system`
// - installing a leaking credential helper
// - starting an unauthenticated git daemon
// - overriding the adapter's host-scoped `http.*` header via `-c`
// - shadowing core.sshCommand to bypass the adapter's SSH wrapper
// Patterns are case-insensitive because git config keys themselves are.
DenyArgs: []string{
`(?i)config\s+(--global|--system)`,
`(?i)credential-helper`,
`(?i)\bdaemon\b`,
`(?i)-c\s+http\.`,
`(?i)-c\s+credential\.`,
`(?i)-c\s+core\.sshcommand`,
},
DenyVerbose: nil,
Timeout: 300,
Tips: "Adapter handles auth automatically for clone/fetch/pull/push/submodule based on stored credential type and host scope.",
AdapterName: "git",
},
"psql": {
BinaryName: "psql",
Description: "PostgreSQL CLI — framework-validation preset for the typed-credential adapter (Phase 2b). UI cred-type picker lands in v2; until then operators wire `pg_password_file` credentials via API.",
EnvVars: []EnvVarDef{
{Name: "PGPASSFILE", Desc: "Path to .pgpass file (auto-materialized by adapter when credential_type='pg_password_file')", IsFile: true, Optional: true},
},
DenyArgs: []string{`-c\s+["']?(DROP|TRUNCATE)\b`, `\\!`, `\\copy\s+.*FROM\s+PROGRAM`},
DenyVerbose: nil,
Timeout: 60,
Tips: "Use -A -t for plain output suitable for piping",
AdapterName: "psql",
},
}
// GetPreset returns a preset by name, or nil if not found.
+84 -5
View File
@@ -357,6 +357,10 @@ func matchesBinaryVerbose(args []string, denyPatternsJSON json.RawMessage) strin
func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCLIBinary,
binary string, args []string, cwd string, sandboxKey string, rawCommand string) *Result {
// Attach a per-request scrub bag so adapter-derived secrets stay isolated
// from other tenants/goroutines (see scrub.go WithScrubBag).
ctx = WithScrubBag(ctx)
// Step 0: Reject NUL bytes (defense-in-depth — also checked in Execute()).
if strings.ContainsRune(rawCommand, '\x00') {
return ErrorResult("command contains invalid NUL byte")
@@ -423,13 +427,88 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL
timeout = 30 * time.Second
}
// Step 6b: Resolve adapter from DB row (source of truth, NOT CLIPresets map).
// Passthrough is the default and a no-op — preserves bit-for-bit behavior
// for every legacy preset.
adapterName := ""
if cred.AdapterName != nil {
adapterName = *cred.AdapterName
}
adapter := AdapterFor(adapterName)
// Sandbox is incompatible with non-passthrough adapters in v1 — they need
// to materialize ephemeral files (SSH key, PAT helper) on the host fs that
// the sandboxed process can't read. Reject early before any ephemerals
// would be created.
inSandbox := t.sandboxMgr != nil && sandboxKey != ""
if inSandbox && adapter.Name() != "passthrough" {
return ErrorResult(fmt.Sprintf("credentialed exec: %q adapter not supported in sandbox mode yet", adapter.Name()))
}
if adapter.ShouldInject(args) {
userCred := userCredFromBinary(ctx, cred)
// Plant the resolved exec cwd so adapters (e.g. git) can run any
// pre-flight sub-exec from the right repo, not goclaw's daemon CWD.
prepareCtx := WithExecCwd(ctx, cwd)
inj, err := adapter.Prepare(prepareCtx, cred, userCred, args)
if err != nil {
return ErrorResult(ScrubCredentialsCtx(ctx, fmt.Sprintf("credentialed exec: %s adapter prepare failed: %v", adapter.Name(), err)))
}
if inj != nil {
if inj.Cleanup != nil {
defer func() {
if cerr := inj.Cleanup(); cerr != nil {
// Scrub cerr — os.Remove errors embed the full tmpfile
// path, which is in inj.ScrubValues precisely because
// it's adapter-sensitive (e.g. SSH key keypath).
slog.Warn("security.adapter_cleanup_failed",
"adapter", adapter.Name(),
"binary", binary,
"error", ScrubCredentialsCtx(ctx, cerr.Error()),
)
}
}()
}
if len(inj.ArgvPrefix) > 0 {
args = append(append([]string{}, inj.ArgvPrefix...), args...)
}
for k, v := range inj.Env {
envMap[k] = v
}
if len(inj.ScrubValues) > 0 {
AddScrubValuesCtx(ctx, inj.ScrubValues...)
}
emitSystemEnvInjectionAudit(adapter.Name(), binary,
store.CredentialUserIDFromContext(ctx), inj, cred.UserHostScope)
}
}
// Step 7: Execute — sandbox or host
if t.sandboxMgr != nil && sandboxKey != "" {
if inSandbox {
return t.executeCredentialedSandbox(ctx, absPath, args, cwd, sandboxKey, envMap, timeout)
}
return t.executeCredentialedHost(ctx, absPath, args, cwd, envMap, timeout)
}
// userCredFromBinary synthesizes a *SecureCLIUserCredential from the fields
// LookupByBinary's LEFT JOIN populated on the binary row. Returns nil when
// no user credential exists (UserEnv empty + no typed metadata).
func userCredFromBinary(ctx context.Context, bin *store.SecureCLIBinary) *store.SecureCLIUserCredential {
if bin == nil {
return nil
}
if len(bin.UserEnv) == 0 && bin.UserCredentialType == nil && bin.UserHostScope == nil {
return nil
}
return &store.SecureCLIUserCredential{
BinaryID: bin.ID,
UserID: store.CredentialUserIDFromContext(ctx),
EncryptedEnv: bin.UserEnv,
CredentialType: bin.UserCredentialType,
HostScope: bin.UserHostScope,
}
}
func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error) {
envMap := make(map[string]string)
if cred == nil {
@@ -566,13 +645,13 @@ func (t *ExecTool) executeCredentialedSandbox(ctx context.Context, absPath strin
output += "STDERR:\n" + result.Stderr
}
if result.ExitCode != 0 {
scrubbed := ScrubCredentials(output)
scrubbed := ScrubCredentialsCtx(ctx, output)
return credentialedExecFailError(absPath, args, result.ExitCode, scrubbed+MaybeSandboxHint(result.ExitCode, scrubbed))
}
if output == "" {
output = "(command completed with no output)"
}
output = ScrubCredentials(output)
output = ScrubCredentialsCtx(ctx, output)
output = capExecOutput(output, execMaxOutputChars)
return SilentResult(output)
}
@@ -644,13 +723,13 @@ func formatCredentialedResult(binary string, args []string,
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
return credentialedExecFailError(binary, args, exitCode, ScrubCredentials(output))
return credentialedExecFailError(binary, args, exitCode, ScrubCredentialsCtx(ctx, output))
}
if output == "" {
output = "(command completed with no output)"
}
output = ScrubCredentials(output)
output = ScrubCredentialsCtx(ctx, output)
output = capExecOutput(output, execMaxOutputChars)
return SilentResult(output)
}
@@ -0,0 +1,126 @@
// Regression tests pinning the success/failure-path output scrubber to the
// per-request bag (AddScrubValuesCtx), not the package-global slice.
//
// Why: adapter ScrubValues are registered into the per-request bag during
// Prepare. If formatCredentialedResult / executeCredentialedSandbox call the
// non-Ctx ScrubCredentials, the bag is ignored — so non-GitHub PATs (GitLab
// `glpat-…`, Bitbucket app passwords, Gitea tokens, Azure DevOps PATs) and
// SSH key tmpfile paths would leak into stdout/stderr returned to the agent.
// Locks AC6 against future regressions.
package tools
import (
"context"
"os/exec"
"strings"
"testing"
"time"
)
// 1. Success path: adapter ScrubValues registered via bag are stripped from
// stdout in the final *Result.ForLLM. Uses a sentinel that does NOT match
// any built-in credentialPatterns regex (no `ghp_`, no `aws_`, no
// `://user:pass@`) so the bag is the only thing that could redact it.
func TestFormatCredentialedResult_HonorsScrubBag_Success(t *testing.T) {
ctx := WithScrubBag(context.Background())
secret := "glpat-NONSENSE_GITLAB_PAT_VALUE_XYZ123" // not matched by global regex
AddScrubValuesCtx(ctx, secret)
// Avoid any keyword-style prefix (`token=`, `password:`, `authorization=`, …)
// so the global credentialPatterns regex doesn't redact this — only the
// per-request bag can. That's the whole point of this regression test.
stdout := "cloning into 'repo'…\nremote: rejected by " + secret + " for user\ndone.\n"
res := formatCredentialedResult("/usr/bin/git", []string{"clone", "x"}, stdout, "", nil, ctx, time.Minute)
if res == nil {
t.Fatal("nil result")
}
if strings.Contains(res.ForLLM, secret) {
t.Fatalf("non-GitHub PAT leaked into ForLLM: %q", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "[REDACTED]") {
t.Fatalf("expected [REDACTED] placeholder in scrubbed output, got %q", res.ForLLM)
}
}
// 2. Failure path: same guarantee on the error branch (exit != 0). This is
// the more dangerous case in practice — error messages often quote the
// failing URL/header that contains the token.
func TestFormatCredentialedResult_HonorsScrubBag_Failure(t *testing.T) {
ctx := WithScrubBag(context.Background())
keyPath := "/tmp/goclaw-gitkey-SENTINEL_PATH_VALUE_xyz789"
AddScrubValuesCtx(ctx, keyPath)
stderr := "Load key \"" + keyPath + "\": Permission denied\nfatal: Could not read from remote\n"
// Synthesize a real *exec.ExitError so the type-assertion branch fires.
cmd := exec.Command("sh", "-c", "exit 128")
_ = cmd.Run()
exitErr := &exec.ExitError{ProcessState: cmd.ProcessState}
res := formatCredentialedResult("/usr/bin/git", []string{"clone", "x"}, "", stderr, exitErr, ctx, time.Minute)
if res == nil {
t.Fatal("nil result")
}
if strings.Contains(res.ForLLM, keyPath) {
t.Fatalf("SSH key tmpfile path leaked into ForLLM error: %q", res.ForLLM)
}
if strings.Contains(res.ForUser, keyPath) {
t.Fatalf("SSH key tmpfile path leaked into ForUser error: %q", res.ForUser)
}
}
// 3. Negative control: without a bag in ctx, the same secret leaks (proves
// the test is actually exercising the bag path, not getting silently
// scrubbed by a global match).
func TestFormatCredentialedResult_NoBag_LeaksNonGlobalSecret(t *testing.T) {
ctx := context.Background() // no WithScrubBag
secret := "glpat-NONSENSE_GITLAB_PAT_VALUE_CONTROL"
// Same evasion as test 1 — no keyword-style prefix, so a global regex
// match would have to come from the secret value itself (which it doesn't).
stdout := "remote: rejected by " + secret + " for user\n"
res := formatCredentialedResult("/usr/bin/git", []string{"clone", "x"}, stdout, "", nil, ctx, time.Minute)
if !strings.Contains(res.ForLLM, secret) {
t.Fatalf("control failed — secret was scrubbed without a bag, so test 1's coverage claim is wrong. ForLLM=%q", res.ForLLM)
}
}
// 4. Sanity: empty ctx + empty bag must not panic and must return the regex
// pass intact (well-known ghp_… still gets redacted).
func TestFormatCredentialedResult_EmptyBag_StillRunsGlobalRegex(t *testing.T) {
ctx := WithScrubBag(context.Background())
// classic GitHub PAT shape — matched by the package's regex pass
classicPAT := "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
stdout := "token=" + classicPAT + "\n"
res := formatCredentialedResult("/usr/bin/git", nil, stdout, "", nil, ctx, time.Minute)
if strings.Contains(res.ForLLM, classicPAT) {
t.Fatalf("classic PAT not redacted by regex pass: %q", res.ForLLM)
}
}
// 5. Defensive: confirm the timeout path doesn't surface stderr at all
// (it returns the timeout marker, not the captured output), so no leak vector.
func TestFormatCredentialedResult_TimeoutPath_NoStderrLeak(t *testing.T) {
ctx, cancel := context.WithTimeout(WithScrubBag(context.Background()), 1*time.Nanosecond)
defer cancel()
time.Sleep(1 * time.Millisecond) // make sure deadline elapsed
stderr := "remote auth token=ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"
res := formatCredentialedResult("/usr/bin/git", nil, "", stderr,
errExitForTimeoutTest{}, ctx, 1*time.Nanosecond)
// On timeout the formatter returns a fixed string mentioning the binary,
// not the captured stderr — so no token in output regardless.
if strings.Contains(res.ForLLM, "ghp_") {
t.Fatalf("timeout path leaked captured stderr: %q", res.ForLLM)
}
}
// errExitForTimeoutTest is a marker error for the timeout-path test. The real
// formatCredentialedResult checks ctx.Err() before unwrapping, so any
// non-ExitError suffices.
type errExitForTimeoutTest struct{}
func (errExitForTimeoutTest) Error() string { return "context deadline exceeded" }
+85
View File
@@ -1,6 +1,7 @@
package tools
import (
"context"
"regexp"
"strings"
"sync"
@@ -112,6 +113,90 @@ func AddCredentialScrubValues(values ...string) {
}
}
// --- Per-request scrub bag (multi-tenant safe) ---
//
// The package-global credentialScrubValues above accumulates across all
// goroutines for the life of the process. For the adapter pipeline (Phase 2+)
// each exec gets its own scrub bag via context so tenant A's credentials never
// reach tenant B's output, even when adapters run concurrently.
type scrubBag struct {
mu sync.RWMutex
values []string
}
type scrubBagKey struct{}
type execCwdKey struct{}
// WithExecCwd returns a context carrying the working directory the
// credentialed exec will use. Adapters consult this so any pre-flight
// sub-exec (e.g. `git config --get remote.origin.url`) runs inside the
// caller's repo, not goclaw's daemon CWD.
func WithExecCwd(ctx context.Context, cwd string) context.Context {
if cwd == "" {
return ctx
}
return context.WithValue(ctx, execCwdKey{}, cwd)
}
// ExecCwdFromContext returns the cwd planted by WithExecCwd, or empty.
func ExecCwdFromContext(ctx context.Context) string {
if s, ok := ctx.Value(execCwdKey{}).(string); ok {
return s
}
return ""
}
// WithScrubBag returns a context carrying a fresh per-request scrub list.
// Call at the top of executeCredentialed; pass the returned ctx downstream.
func WithScrubBag(ctx context.Context) context.Context {
return context.WithValue(ctx, scrubBagKey{}, &scrubBag{})
}
// AddScrubValuesCtx appends adapter-derived secrets to the per-request bag.
// No-op when ctx has no bag (legacy callers). Skips values shorter than 6
// chars to avoid false-positive matches like "x" or "1".
func AddScrubValuesCtx(ctx context.Context, vs ...string) {
bag, ok := ctx.Value(scrubBagKey{}).(*scrubBag)
if !ok {
return
}
bag.mu.Lock()
defer bag.mu.Unlock()
for _, v := range vs {
if len(v) >= 6 {
bag.values = append(bag.values, v)
}
}
}
// ScrubCredentialsCtx runs the same regex pass as ScrubCredentials AND any
// per-request bag values, but does NOT consult the package-global slice.
// Use this in the adapter pipeline so one tenant's secrets cannot leak into
// another tenant's output through the shared global.
func ScrubCredentialsCtx(ctx context.Context, text string) string {
for _, pat := range credentialPatterns {
text = pat.ReplaceAllString(text, redactedPlaceholder)
}
if bag, ok := ctx.Value(scrubBagKey{}).(*scrubBag); ok {
bag.mu.RLock()
vals := append([]string(nil), bag.values...)
bag.mu.RUnlock()
for _, v := range vals {
text = strings.ReplaceAll(text, v, redactedPlaceholder)
}
}
// Dynamic server-IP values still apply — they're infra metadata, not creds.
dynamicScrubMu.RLock()
dyn := dynamicScrubValues
dynamicScrubMu.RUnlock()
for _, v := range dyn {
text = strings.ReplaceAll(text, v, serverIPPlaceholder)
}
return text
}
// ScrubCredentials replaces known credential patterns and dynamic values in text.
func ScrubCredentials(text string) string {
for _, pat := range credentialPatterns {
@@ -104,6 +104,9 @@ func (s *stubSecureCLIStore) GetUserCredentials(ctx context.Context, binaryID uu
func (s *stubSecureCLIStore) SetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte) error {
return nil
}
func (s *stubSecureCLIStore) SetUserCredentialsTyped(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte, credentialType, hostScope *string) error {
return nil
}
func (s *stubSecureCLIStore) DeleteUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) error {
return nil
}
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 72
const RequiredSchemaVersion uint = 73
@@ -0,0 +1,6 @@
ALTER TABLE secure_cli_user_credentials
DROP COLUMN IF EXISTS credential_type,
DROP COLUMN IF EXISTS host_scope;
ALTER TABLE secure_cli_binaries
DROP COLUMN IF EXISTS adapter_name;
@@ -0,0 +1,12 @@
-- Credential adapter framework: storage substrate for typed credentials.
-- credential_type carries the cred shape ('env' | 'pat' | 'ssh_key' | future).
-- host_scope binds credentials to a specific hostname (e.g. 'github.com').
-- adapter_name routes the binary to the right CredentialAdapter at exec time.
-- All three columns NULL-by-default to preserve legacy passthrough behavior.
ALTER TABLE secure_cli_user_credentials
ADD COLUMN IF NOT EXISTS credential_type TEXT NULL,
ADD COLUMN IF NOT EXISTS host_scope TEXT NULL;
ALTER TABLE secure_cli_binaries
ADD COLUMN IF NOT EXISTS adapter_name TEXT NULL;
+217
View File
@@ -0,0 +1,217 @@
//go:build integration
package integration
// Phase 3 (issue #82) — end-to-end PAT injection via the git adapter.
//
// What we prove here that unit tests cannot:
// - A real `git clone` over HTTP succeeds when the adapter's Injection env
// is passed through, against a server that requires the bearer token.
// - The token never lands in `.git/config`, the remote URL, or any
// credential helper after clone — i.e. the GIT_CONFIG_COUNT shape leaves
// no on-disk trace.
// - Submodule resolve honors the host scope: parent.origin = trusted host
// resolves the same host for the submodule clone; cross-host stops
// with `errCredentialHostMismatch` before any network call.
//
// These map to acceptance criteria AC1 + AC4 from the plan.
import (
"context"
"errors"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
const testPAT = "ghp_TESTtokenABCDEFGHIJKLMNOPQRSTUV1234"
func patCred(t *testing.T, host string) *store.SecureCLIUserCredential {
t.Helper()
ct := "pat"
hs := host
return &store.SecureCLIUserCredential{
CredentialType: &ct,
HostScope: &hs,
EncryptedEnv: []byte(`{"token":"` + testPAT + `"}`),
}
}
// applyInjection turns an Injection into an env slice merged onto os.Environ,
// mirroring what credentialed_exec.go does at run time. GIT_SSL_NO_VERIFY is
// added because httptest.NewTLSServer uses a self-signed cert; production
// callers will hit real CAs and never need this.
func applyInjection(t *testing.T, inj *tools.Injection) []string {
t.Helper()
env := append(os.Environ(), "GIT_SSL_NO_VERIFY=true")
for k, v := range inj.Env {
env = append(env, k+"="+v)
}
return env
}
// TestGitAdapter_PAT_NoLeakInClonedRepo (AC1): real clone works through the
// adapter, and the resulting .git/config carries the remote URL only — no
// Authorization header, no embedded token, no credential helper entry.
func TestGitAdapter_PAT_NoLeakInClonedRepo(t *testing.T) {
t.Parallel()
repoRoot := t.TempDir()
makeBareRepo(t, repoRoot, "demo")
srv := startGitHTTPServer(t, repoRoot, testPAT)
u, _ := url.Parse(srv.URL)
host := u.Host // "127.0.0.1:NNNN" — preserved with port by normalizeHost
cred := patCred(t, host)
cloneURL := srv.URL + "/demo.git"
dest := filepath.Join(t.TempDir(), "checkout")
adapter := tools.AdapterFor("git")
argv := []string{"clone", cloneURL, dest}
inj, err := adapter.Prepare(context.Background(), nil, cred, argv)
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if len(inj.ArgvPrefix) != 0 {
t.Fatalf("token leaked to argv prefix: %v", inj.ArgvPrefix)
}
if _, ok := inj.Env["GIT_CONFIG_VALUE_0"]; !ok {
t.Fatalf("missing GIT_CONFIG_VALUE_0 in injection env")
}
cmd := exec.Command("git", argv...)
cmd.Env = applyInjection(t, inj)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("clone failed: %v\n%s", err, out)
}
cfg, err := os.ReadFile(filepath.Join(dest, ".git", "config"))
if err != nil {
t.Fatalf("read .git/config: %v", err)
}
cfgStr := string(cfg)
if strings.Contains(cfgStr, testPAT) {
t.Fatalf("PAT leaked into .git/config:\n%s", cfgStr)
}
if strings.Contains(strings.ToLower(cfgStr), "authorization") {
t.Fatalf("auth header leaked into .git/config:\n%s", cfgStr)
}
if strings.Contains(strings.ToLower(cfgStr), "credential.helper") {
t.Fatalf("credential helper leaked into .git/config:\n%s", cfgStr)
}
// Verify the remote URL is the clean URL, no embedded userinfo.
remoteURL, err := exec.Command("git", "-C", dest, "config", "--get", "remote.origin.url").Output()
if err != nil {
t.Fatalf("read remote url: %v", err)
}
if got := strings.TrimSpace(string(remoteURL)); got != cloneURL {
t.Fatalf("remote URL rewritten with credentials\nwant: %s\ngot: %s", cloneURL, got)
}
}
// TestGitAdapter_PAT_SubmoduleSameHost (AC4 happy): a fetch inside an existing
// checkout whose origin matches the cred host resolves without error.
func TestGitAdapter_PAT_SubmoduleSameHost(t *testing.T) {
t.Parallel()
repoRoot := t.TempDir()
makeBareRepo(t, repoRoot, "parent")
srv := startGitHTTPServer(t, repoRoot, testPAT)
u, _ := url.Parse(srv.URL)
host := u.Host
dest := filepath.Join(t.TempDir(), "parent-checkout")
cred := patCred(t, host)
cloneURL := srv.URL + "/parent.git"
adapter := tools.AdapterFor("git")
// 1. Clone to set up origin.
cloneArgv := []string{"clone", cloneURL, dest}
inj, err := adapter.Prepare(context.Background(), nil, cred, cloneArgv)
if err != nil {
t.Fatalf("Prepare clone: %v", err)
}
cmd := exec.Command("git", cloneArgv...)
cmd.Env = applyInjection(t, inj)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("clone: %v\n%s", err, out)
}
// 2. Fetch — adapter resolves remote.origin.url via `git config --get` and
// must match the cred host. Production passes argv without `-C`; cwd is
// set on the cmd struct (see credentialed_exec.go). Mirror that.
fetchArgv := []string{"fetch", "origin"}
ctx := tools.WithExecCwd(context.Background(), dest)
inj, err = adapter.Prepare(ctx, nil, cred, fetchArgv)
if err != nil {
t.Fatalf("Prepare fetch on same host: %v", err)
}
cmd = exec.Command("git", fetchArgv...)
cmd.Dir = dest
cmd.Env = applyInjection(t, inj)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("fetch: %v\n%s", err, out)
}
}
// TestGitAdapter_PAT_SubmoduleCrossHostFails (AC4 negative): cred bound to a
// different host than the parent's origin must fail before any HTTP call. The
// error message must not leak the token.
func TestGitAdapter_PAT_SubmoduleCrossHostFails(t *testing.T) {
t.Parallel()
repoRoot := t.TempDir()
makeBareRepo(t, repoRoot, "parent")
srv := startGitHTTPServer(t, repoRoot, testPAT)
u, _ := url.Parse(srv.URL)
actualHost := u.Host
dest := filepath.Join(t.TempDir(), "parent-checkout")
cloneURL := srv.URL + "/parent.git"
adapter := tools.AdapterFor("git")
// Clone with a matching cred first to set up the checkout.
good := patCred(t, actualHost)
cloneArgv := []string{"clone", cloneURL, dest}
inj, err := adapter.Prepare(context.Background(), nil, good, cloneArgv)
if err != nil {
t.Fatalf("Prepare clone: %v", err)
}
cmd := exec.Command("git", cloneArgv...)
cmd.Env = applyInjection(t, inj)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("clone: %v\n%s", err, out)
}
// Now retry fetch with a cred bound to a different host.
wrong := patCred(t, "evil.example.com")
fetchArgv := []string{"fetch", "origin"}
ctx := tools.WithExecCwd(context.Background(), dest)
if _, err := adapter.Prepare(ctx, nil, wrong, fetchArgv); err == nil {
t.Fatalf("expected host mismatch error, got nil")
} else {
// Adapter exposes errCredentialHostMismatch as unexported; assert by
// shape: error string must name both hosts but never the token.
msg := err.Error()
if strings.Contains(msg, testPAT) {
t.Fatalf("token leaked into mismatch error: %s", msg)
}
if !strings.Contains(msg, "evil.example.com") {
t.Fatalf("error missing cred host: %s", msg)
}
// errors.Is fallback in case the adapter exports a sentinel later.
_ = errors.Is
}
}
+187
View File
@@ -0,0 +1,187 @@
//go:build integration
package integration
// Phase 4 (issue #82) — SSH path end-to-end lifecycle test.
//
// Why no real sshd: spawning an OpenSSH daemon in CI is heavy (image must
// carry openssh-server) and the lifecycle properties we care about
// (tmpfile 0600, cleanup-on-success, cleanup-on-exec-failure, env shape
// reaching the child) are observable with a fake exec that just reads
// GIT_SSH_COMMAND and reports back. Real sshd is reserved for manual
// security review per Phase 6.
//
// This file proves:
// - The keypath surfaced via Injection.ScrubValues[0] is a real 0600
// tmpfile on disk while the exec runs.
// - cleanup() removes it. The file is gone after cleanup, regardless of
// exec success/failure.
// - GIT_SSH_COMMAND propagates through the env passed to the child.
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"golang.org/x/crypto/ssh"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
func makeTestKeyJSON(t *testing.T) []byte {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
block, err := ssh.MarshalPrivateKey(priv, "")
if err != nil {
t.Fatalf("marshal: %v", err)
}
encoded := base64.StdEncoding.EncodeToString(block.Bytes)
pem := "-----BEGIN OPENSSH PRIVATE KEY-----\n" + encoded + "\n-----END OPENSSH PRIVATE KEY-----\n"
blob, _ := json.Marshal(map[string]string{"key": pem})
return blob
}
func sshCredFor(t *testing.T, host string) *store.SecureCLIUserCredential {
t.Helper()
ct, hs := "ssh_key", host
return &store.SecureCLIUserCredential{
CredentialType: &ct,
HostScope: &hs,
EncryptedEnv: makeTestKeyJSON(t),
}
}
// TestGitAdapter_SSH_TmpfileLifecycle (AC2): keypath exists during exec, is
// 0600, and is removed after cleanup runs.
func TestGitAdapter_SSH_TmpfileLifecycle(t *testing.T) {
t.Parallel()
cred := sshCredFor(t, "github.com")
adapter := tools.AdapterFor("git")
inj, err := adapter.Prepare(context.Background(), nil, cred,
[]string{"clone", "git@github.com:o/r.git", "/tmp/dst"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
defer func() {
if inj.Cleanup != nil {
_ = inj.Cleanup()
}
}()
if len(inj.ScrubValues) != 1 {
t.Fatalf("want 1 scrub value (keypath), got %v", inj.ScrubValues)
}
keyPath := inj.ScrubValues[0]
// Exists + 0600 (POSIX only) during the exec window.
st, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("keypath stat: %v", err)
}
if runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 {
t.Fatalf("keypath perms = %o, want 0600", st.Mode().Perm())
}
// Simulate the exec: spawn `env` (or `cmd /c set` on Windows) to
// observe GIT_SSH_COMMAND propagation. We don't run real git — the
// adapter's contract is "env reaches child", which is provable without
// involving git's transport layer.
env := append(os.Environ())
for k, v := range inj.Env {
env = append(env, k+"="+v)
}
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/c", "set")
} else {
cmd = exec.Command("env")
}
cmd.Env = env
out, err := cmd.Output()
if err != nil {
t.Fatalf("env exec: %v", err)
}
if !strings.Contains(string(out), "GIT_SSH_COMMAND=ssh -i "+keyPath) {
t.Fatalf("GIT_SSH_COMMAND not propagated to child env:\n%s", out)
}
// Cleanup → file gone.
if err := inj.Cleanup(); err != nil {
t.Fatalf("cleanup: %v", err)
}
if _, err := os.Stat(keyPath); !os.IsNotExist(err) {
t.Fatalf("post-cleanup stat want ErrNotExist, got %v", err)
}
}
// TestGitAdapter_SSH_CleanupOnExecFailure: cleanup runs even when the
// caller's exec exits non-zero. Mirrors what credentialed_exec.go does via
// `defer inj.Cleanup()`.
func TestGitAdapter_SSH_CleanupOnExecFailure(t *testing.T) {
t.Parallel()
cred := sshCredFor(t, "github.com")
adapter := tools.AdapterFor("git")
inj, err := adapter.Prepare(context.Background(), nil, cred,
[]string{"clone", "git@github.com:o/r.git", "/tmp/dst"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
keyPath := inj.ScrubValues[0]
// Mimic credentialed_exec.go: defer cleanup, run command, observe exit.
func() {
defer inj.Cleanup()
// Force a non-zero exit.
cmd := exec.Command(falseBin(), "anyarg")
_ = cmd.Run() // err expected, ignored
}()
if _, err := os.Stat(keyPath); !os.IsNotExist(err) {
t.Fatalf("post-failure cleanup did not remove keypath; stat err = %v", err)
}
}
// TestGitAdapter_SSH_HostMismatch_NoTmpfile: rejected host mismatch must
// NOT leave an orphaned tmpfile in os.TempDir().
func TestGitAdapter_SSH_HostMismatch_NoTmpfile(t *testing.T) {
t.Parallel()
cred := sshCredFor(t, "github.com")
adapter := tools.AdapterFor("git")
before, _ := filepath.Glob(filepath.Join(os.TempDir(), "goclaw-gitkey-*"))
_, err := adapter.Prepare(context.Background(), nil, cred,
[]string{"clone", "git@gitlab.com:o/r.git"})
if err == nil {
t.Fatal("expected host mismatch error")
}
after, _ := filepath.Glob(filepath.Join(os.TempDir(), "goclaw-gitkey-*"))
if len(after) > len(before) {
t.Fatalf("orphaned tmpfile after rejected Prepare: before=%d after=%d new=%v",
len(before), len(after), after)
}
}
func falseBin() string {
if runtime.GOOS == "windows" {
// cmd.exe /c exit 1 — but `exit` is a builtin, use a guaranteed-fail call.
return "cmd"
}
return "false"
}
+110
View File
@@ -0,0 +1,110 @@
//go:build integration
package integration
// Local git http-backend test server used by Phase 3 PAT integration tests.
//
// Why this exists:
// - The git adapter promises PAT injection via GIT_CONFIG_COUNT/KEY_0/VALUE_0
// so the token never lands on argv or .git/config.
// - To prove that end-to-end we need a real git server that demands an
// Authorization header and rejects anonymous clones.
// - We wrap `git-http-backend` (ships with git) behind httptest so the test
// is self-contained — no network, no docker.
//
// Auth model:
// - Server checks `Authorization: Bearer <expectedToken>` on every request.
// - Missing/wrong → 401, which makes git fail immediately.
// - Matching → defer to git-http-backend CGI.
//
// macOS note: git-http-backend lives under `git --exec-path`. We probe that
// and skip if not present (some minimal CI images strip it).
import (
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// startGitHTTPServer creates an httptest server backed by git-http-backend
// serving repos under projectRoot. Requests must carry the bearer token.
func startGitHTTPServer(t *testing.T, projectRoot, expectedToken string) *httptest.Server {
t.Helper()
execPath, err := exec.Command("git", "--exec-path").Output()
if err != nil {
t.Skipf("git --exec-path unavailable: %v", err)
}
backend := filepath.Join(strings.TrimSpace(string(execPath)), "git-http-backend")
if _, err := os.Stat(backend); err != nil {
t.Skipf("git-http-backend not installed at %s: %v", backend, err)
}
handler := &cgi.Handler{
Path: backend,
Env: []string{
"GIT_PROJECT_ROOT=" + projectRoot,
"GIT_HTTP_EXPORT_ALL=1",
// http-backend uses REMOTE_USER for ident; harmless placeholder.
"REMOTE_USER=pat-test",
},
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
got := r.Header.Get("Authorization")
if got != "Bearer "+expectedToken {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
handler.ServeHTTP(w, r)
})
// TLS required: the git adapter writes `http.https://<host>/.extraheader`
// which only matches HTTPS URLs. Production PATs must travel over TLS, so
// the test mirrors that constraint.
srv := httptest.NewTLSServer(mux)
t.Cleanup(srv.Close)
return srv
}
// makeBareRepo seeds projectRoot/<name>.git with a single commit so clone has
// something to fetch. Returns the on-disk path (useful for assertions).
func makeBareRepo(t *testing.T, projectRoot, name string) string {
t.Helper()
// Working tree to seed history.
work := t.TempDir()
mustRun(t, work, "git", "init", "-q", "-b", "main")
mustRun(t, work, "git", "config", "user.email", "ci@test.local")
mustRun(t, work, "git", "config", "user.name", "CI")
if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatalf("seed file: %v", err)
}
mustRun(t, work, "git", "add", ".")
mustRun(t, work, "git", "commit", "-q", "-m", "init")
// Bare repo under project root.
bare := filepath.Join(projectRoot, name+".git")
mustRun(t, "", "git", "clone", "-q", "--bare", work, bare)
// http-backend requires this to serve smart HTTP without auth bypass.
mustRun(t, bare, "git", "config", "http.receivepack", "true")
mustRun(t, bare, "git", "update-server-info")
return bare
}
func mustRun(t *testing.T, cwd string, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
if cwd != "" {
cmd.Dir = cwd
}
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
}
}
@@ -0,0 +1,231 @@
//go:build integration
package integration
// Phase 1 (issue #82): verify the credential-type schema delta on PostgreSQL.
// - adapter_name column on secure_cli_binaries
// - credential_type, host_scope columns on secure_cli_user_credentials
// - SetUserCredentialsTyped writes the new columns
// - LookupByBinary projects AdapterName + UserCredentialType + UserHostScope
// - Legacy paths (no adapter, SetUserCredentials with no type) keep all
// new columns NULL for backward compat
//
// These tests run against a freshly-migrated test DB (migration 73 applied).
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
// pgColumnExists checks information_schema for a column on the given table
// in the public schema.
func pgColumnExists(t *testing.T, db *sql.DB, table, column string) bool {
t.Helper()
var exists bool
err := db.QueryRow(
`SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
)`, table, column,
).Scan(&exists)
if err != nil {
t.Fatalf("information_schema lookup %s.%s: %v", table, column, err)
}
return exists
}
func TestPG_SchemaHasPhase1Columns(t *testing.T) {
db := testDB(t)
if !pgColumnExists(t, db, "secure_cli_binaries", "adapter_name") {
t.Fatalf("secure_cli_binaries.adapter_name missing — migration 73 not applied")
}
if !pgColumnExists(t, db, "secure_cli_user_credentials", "credential_type") {
t.Fatalf("secure_cli_user_credentials.credential_type missing")
}
if !pgColumnExists(t, db, "secure_cli_user_credentials", "host_scope") {
t.Fatalf("secure_cli_user_credentials.host_scope missing")
}
}
func TestPG_CreateBinary_AdapterNameRoundTrip(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := store.WithTenantID(context.Background(), tenantID)
s := pg.NewPGSecureCLIStore(db, testEncryptionKey)
adapter := "git"
bin := &store.SecureCLIBinary{
BinaryName: "git-phase1-test-" + uuid.New().String()[:8],
Description: "Phase 1 adapter binary",
IsGlobal: true,
Enabled: true,
CreatedBy: "u-tester",
AdapterName: &adapter,
EncryptedEnv: []byte(`{}`),
}
if err := s.Create(ctx, bin); err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM secure_cli_binaries WHERE id = $1", bin.ID)
})
got, err := s.Get(ctx, bin.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.AdapterName == nil || *got.AdapterName != "git" {
t.Fatalf("expected adapter_name=git, got %v", got.AdapterName)
}
}
func TestPG_SetUserCredentialsTyped_RoundTrip(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := store.WithTenantID(context.Background(), tenantID)
s := pg.NewPGSecureCLIStore(db, testEncryptionKey)
binID := seedGateBinary(t, db, tenantID, "git-typed-cred-"+uuid.New().String()[:8], true)
credType := "pat"
hostScope := "github.com"
plaintext := []byte(`{"token":"ghp_xxx"}`)
if err := s.SetUserCredentialsTyped(ctx, binID, "u-1", plaintext, &credType, &hostScope); err != nil {
t.Fatalf("SetUserCredentialsTyped: %v", err)
}
got, err := s.GetUserCredentials(ctx, binID, "u-1")
if err != nil {
t.Fatalf("GetUserCredentials: %v", err)
}
if got == nil {
t.Fatalf("expected credential, got nil")
}
if got.CredentialType == nil || *got.CredentialType != "pat" {
t.Fatalf("expected credential_type=pat, got %v", got.CredentialType)
}
if got.HostScope == nil || *got.HostScope != "github.com" {
t.Fatalf("expected host_scope=github.com, got %v", got.HostScope)
}
if string(got.EncryptedEnv) != string(plaintext) {
t.Fatalf("decrypted env mismatch: got %q want %q", got.EncryptedEnv, plaintext)
}
}
// Legacy SetUserCredentials (untyped) must store NULL for credential_type / host_scope.
func TestPG_SetUserCredentials_LegacyLeavesTypeColumnsNull(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := store.WithTenantID(context.Background(), tenantID)
s := pg.NewPGSecureCLIStore(db, testEncryptionKey)
binID := seedGateBinary(t, db, tenantID, "git-legacy-cred-"+uuid.New().String()[:8], true)
if err := s.SetUserCredentials(ctx, binID, "u-legacy", []byte(`{"GITHUB_TOKEN":"x"}`)); err != nil {
t.Fatalf("SetUserCredentials: %v", err)
}
got, err := s.GetUserCredentials(ctx, binID, "u-legacy")
if err != nil {
t.Fatalf("GetUserCredentials: %v", err)
}
if got == nil {
t.Fatalf("expected credential, got nil")
}
if got.CredentialType != nil {
t.Fatalf("expected credential_type NULL for legacy, got %q", *got.CredentialType)
}
if got.HostScope != nil {
t.Fatalf("expected host_scope NULL for legacy, got %q", *got.HostScope)
}
}
// LookupByBinary must populate AdapterName + UserCredentialType + UserHostScope
// from the LEFT JOIN onto secure_cli_user_credentials.
func TestPG_LookupByBinary_ProjectsNewColumns(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := store.WithTenantID(context.Background(), tenantID)
s := pg.NewPGSecureCLIStore(db, testEncryptionKey)
binName := "git-lookup-proj-" + uuid.New().String()[:8]
adapter := "git"
bin := &store.SecureCLIBinary{
BinaryName: binName,
Description: "Phase 1 lookup projection",
IsGlobal: true,
Enabled: true,
CreatedBy: "u-tester",
AdapterName: &adapter,
EncryptedEnv: []byte(`{}`),
}
if err := s.Create(ctx, bin); err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM secure_cli_user_credentials WHERE binary_id = $1", bin.ID)
db.Exec("DELETE FROM secure_cli_binaries WHERE id = $1", bin.ID)
})
credType := "pat"
hostScope := "github.com"
if err := s.SetUserCredentialsTyped(ctx, bin.ID, "u-1", []byte(`{"token":"x"}`), &credType, &hostScope); err != nil {
t.Fatalf("SetUserCredentialsTyped: %v", err)
}
got, err := s.LookupByBinary(ctx, binName, nil, "u-1")
if err != nil {
t.Fatalf("LookupByBinary: %v", err)
}
if got == nil {
t.Fatalf("expected binary, got nil")
}
if got.AdapterName == nil || *got.AdapterName != "git" {
t.Fatalf("expected AdapterName=git, got %v", got.AdapterName)
}
if got.UserCredentialType == nil || *got.UserCredentialType != "pat" {
t.Fatalf("expected UserCredentialType=pat, got %v", got.UserCredentialType)
}
if got.UserHostScope == nil || *got.UserHostScope != "github.com" {
t.Fatalf("expected UserHostScope=github.com, got %v", got.UserHostScope)
}
}
// Backward-compat: binary with no adapter + no user credential must return
// successfully with all three new fields NULL.
func TestPG_LookupByBinary_BackwardCompatibleNulls(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := store.WithTenantID(context.Background(), tenantID)
s := pg.NewPGSecureCLIStore(db, testEncryptionKey)
binName := "gh-bc-null-" + uuid.New().String()[:8]
seedGateBinary(t, db, tenantID, binName, true)
got, err := s.LookupByBinary(ctx, binName, nil, "")
if err != nil {
t.Fatalf("LookupByBinary: %v", err)
}
if got == nil {
t.Fatalf("expected binary, got nil")
}
if got.AdapterName != nil {
t.Fatalf("expected AdapterName NULL, got %q", *got.AdapterName)
}
if got.UserCredentialType != nil {
t.Fatalf("expected UserCredentialType NULL, got %q", *got.UserCredentialType)
}
if got.UserHostScope != nil {
t.Fatalf("expected UserHostScope NULL, got %q", *got.UserHostScope)
}
}
@@ -89,7 +89,23 @@
"deleted": "User credentials deleted",
"deleteFailed": "Failed to delete user credentials",
"envRequired": "At least one environment variable is required",
"mergeHint": "Chat users (Telegram, Discord, etc.) must be merged into a tenant user first via Contacts page before they can have per-user credentials."
"mergeHint": "Chat users (Telegram, Discord, etc.) must be merged into a tenant user first via Contacts page before they can have per-user credentials.",
"credentialType": "Credential Type",
"credentialTypeEnv": "Environment Variables (legacy)",
"credentialTypePAT": "Personal Access Token (PAT)",
"credentialTypeSSH": "SSH Private Key",
"hostScope": "Host Scope",
"hostScopePlaceholder": "github.com or gitlab.example.com:2222",
"hostScopeRequired": "Host scope is required for PAT and SSH credentials",
"hostScopeInvalid": "Host scope must be a valid hostname (optional :port)",
"token": "Personal Access Token",
"tokenPlaceholder": "ghp_… / glpat-… (never echoed back)",
"sshKey": "SSH Private Key (PEM)",
"sshKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----",
"sshKeyInvalid": "SSH key is not a valid unencrypted PEM private key",
"passphraseUnsupported": "Passphrase-protected SSH keys are not supported. Provide an unencrypted key.",
"secretMasked": "•••••••• (stored, leave blank to keep)",
"noSecret": "No secret stored yet"
},
"grants": {
"title": "Agent Grants — {{name}}",
@@ -89,7 +89,23 @@
"deleted": "Đã xóa thông tin người dùng",
"deleteFailed": "Không thể xóa thông tin người dùng",
"envRequired": "Cần ít nhất một biến môi trường",
"mergeHint": "Người dùng chat (Telegram, Discord...) cần được gộp vào tenant user qua trang Contacts trước khi có thể thiết lập credentials riêng."
"mergeHint": "Người dùng chat (Telegram, Discord...) cần được gộp vào tenant user qua trang Contacts trước khi có thể thiết lập credentials riêng.",
"credentialType": "Loại credential",
"credentialTypeEnv": "Biến môi trường (cũ)",
"credentialTypePAT": "Personal Access Token (PAT)",
"credentialTypeSSH": "Khoá riêng SSH",
"hostScope": "Phạm vi host",
"hostScopePlaceholder": "github.com hoặc gitlab.example.com:2222",
"hostScopeRequired": "Bắt buộc nhập phạm vi host cho credential PAT/SSH",
"hostScopeInvalid": "Phạm vi host phải là hostname hợp lệ (có thể kèm :port)",
"token": "Personal Access Token",
"tokenPlaceholder": "ghp_… / glpat-… (không bao giờ hiển thị lại)",
"sshKey": "Khoá riêng SSH (PEM)",
"sshKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----",
"sshKeyInvalid": "Khoá SSH không phải là PEM hợp lệ chưa đặt passphrase",
"passphraseUnsupported": "Hệ thống không hỗ trợ khoá SSH có passphrase. Vui lòng dùng khoá không đặt passphrase.",
"secretMasked": "•••••••• (đã lưu, để trống để giữ nguyên)",
"noSecret": "Chưa có credential được lưu"
},
"grants": {
"title": "Phân quyền Agent — {{name}}",
@@ -89,7 +89,23 @@
"deleted": "用户凭证已删除",
"deleteFailed": "删除用户凭证失败",
"envRequired": "至少需要一个环境变量",
"mergeHint": "聊天用户(Telegram、Discord 等)需要先通过联系人页面合并为租户用户,才能设置独立凭证。"
"mergeHint": "聊天用户(Telegram、Discord 等)需要先通过联系人页面合并为租户用户,才能设置独立凭证。",
"credentialType": "凭证类型",
"credentialTypeEnv": "环境变量(旧)",
"credentialTypePAT": "个人访问令牌 (PAT)",
"credentialTypeSSH": "SSH 私钥",
"hostScope": "主机范围",
"hostScopePlaceholder": "github.com 或 gitlab.example.com:2222",
"hostScopeRequired": "PAT/SSH 凭证必须填写主机范围",
"hostScopeInvalid": "主机范围必须是合法的主机名(可附加 :端口)",
"token": "个人访问令牌",
"tokenPlaceholder": "ghp_… / glpat-…(不会再显示)",
"sshKey": "SSH 私钥 (PEM)",
"sshKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----",
"sshKeyInvalid": "SSH 密钥不是有效的未加密 PEM 私钥",
"passphraseUnsupported": "不支持带 passphrase 的 SSH 密钥,请提供未加密的私钥。",
"secretMasked": "••••••••(已保存,留空则保持不变)",
"noSecret": "尚未保存凭证"
},
"grants": {
"title": "代理授权 — {{name}}",
@@ -0,0 +1,162 @@
/**
* CliCredentialGitFields — Phase 5 typed-credential form for the git adapter.
*
* Rendered conditionally from CLIUserCredentialsDialog when
* `binary.adapter_name === "git"`. Three credential types: env (legacy),
* pat (single token field), ssh_key (textarea). Host scope is required
* for pat/ssh_key — validated client-side AND server-side; this component
* only blocks the network call when the field is empty.
*
* Secret state reset: when the user switches type, all secret state is
* cleared via `useEffect([type])` so a token entered for "PAT" cannot leak
* into the SSH key buffer (or vice versa).
*/
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
export type GitCredentialType = "env" | "pat" | "ssh_key";
export interface CliCredentialGitFieldsProps {
type: GitCredentialType;
onTypeChange: (t: GitCredentialType) => void;
hostScope: string;
onHostScopeChange: (v: string) => void;
token: string;
onTokenChange: (v: string) => void;
privateKey: string;
onPrivateKeyChange: (v: string) => void;
/** Backend error_key for inline display (e.g. "git.cred_ssh_passphrase_unsupported"). */
errorKey?: string;
/** True iff the row already exists and the user is editing — show "secret set" placeholder. */
hasExistingSecret?: boolean;
}
export function CliCredentialGitFields({
type,
onTypeChange,
hostScope,
onHostScopeChange,
token,
onTokenChange,
privateKey,
onPrivateKeyChange,
errorKey,
hasExistingSecret,
}: CliCredentialGitFieldsProps) {
const { t } = useTranslation("cli-credentials");
// Reset all secret state on type switch — prevents a token pasted under
// "PAT" from being submitted when the user later flips to "SSH key".
useEffect(() => {
onTokenChange("");
onPrivateKeyChange("");
// intentionally only depend on `type` — the setters are stable across renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type]);
const passphraseError = errorKey === "git.cred_ssh_passphrase_unsupported";
const sshKeyError = errorKey === "git.cred_ssh_key_invalid";
const hostScopeError =
errorKey === "git.cred_host_scope_required" || errorKey === "git.cred_host_scope_invalid";
return (
<div data-testid="git-credential-fields" className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="git-cred-type">{t("userCredentials.credentialType")}</Label>
<select
id="git-cred-type"
data-testid="git-cred-type"
value={type}
onChange={(e) => onTypeChange(e.target.value as GitCredentialType)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base md:text-sm shadow-xs"
>
<option value="env">{t("userCredentials.credentialTypeEnv")}</option>
<option value="pat">{t("userCredentials.credentialTypePAT")}</option>
<option value="ssh_key">{t("userCredentials.credentialTypeSSH")}</option>
</select>
</div>
{/* Env legacy: caller owns env-vars table; render nothing here. */}
{type !== "env" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="git-cred-host-scope">{t("userCredentials.hostScope")}</Label>
<Input
id="git-cred-host-scope"
data-testid="git-cred-host-scope"
value={hostScope}
onChange={(e) => onHostScopeChange(e.target.value)}
placeholder={t("userCredentials.hostScopePlaceholder")}
autoComplete="off"
spellCheck={false}
aria-invalid={hostScopeError}
/>
{hostScopeError && (
<p data-testid="git-cred-host-scope-error" className="text-xs text-destructive">
{errorKey === "git.cred_host_scope_required"
? t("userCredentials.hostScopeRequired")
: t("userCredentials.hostScopeInvalid")}
</p>
)}
</div>
)}
{type === "pat" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="git-cred-token">{t("userCredentials.token")}</Label>
<Input
id="git-cred-token"
data-testid="git-cred-token"
type="password"
// Non-standard name discourages browser password managers from
// offering to save the PAT into the OS credential store.
name="goclaw-cred-token"
autoComplete="off"
spellCheck={false}
value={token}
onChange={(e) => onTokenChange(e.target.value)}
placeholder={
hasExistingSecret
? t("userCredentials.secretMasked")
: t("userCredentials.tokenPlaceholder")
}
/>
</div>
)}
{type === "ssh_key" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="git-cred-ssh-key">{t("userCredentials.sshKey")}</Label>
<Textarea
id="git-cred-ssh-key"
data-testid="git-cred-ssh-key"
rows={10}
spellCheck={false}
autoComplete="off"
value={privateKey}
onChange={(e) => onPrivateKeyChange(e.target.value)}
placeholder={
hasExistingSecret
? t("userCredentials.secretMasked")
: t("userCredentials.sshKeyPlaceholder")
}
aria-invalid={passphraseError || sshKeyError}
className="font-mono text-base md:text-xs"
/>
{passphraseError && (
<p data-testid="git-cred-passphrase-error" className="text-xs text-destructive">
{t("userCredentials.passphraseUnsupported")}
</p>
)}
{sshKeyError && !passphraseError && (
<p data-testid="git-cred-ssh-key-error" className="text-xs text-destructive">
{t("userCredentials.sshKeyInvalid")}
</p>
)}
</div>
)}
</div>
);
}
@@ -17,6 +17,7 @@ import { toast } from "@/stores/use-toast-store";
import { useHttp } from "@/hooks/use-ws";
import i18next from "i18next";
import { CliCredentialEnvVarsSection, type ManualEnvEntry } from "./cli-credential-env-vars-section";
import { CliCredentialGitFields, type GitCredentialType } from "./cli-credential-git-fields";
import type { SecureCLIBinary } from "./hooks/use-cli-credentials";
import type { CLIEnvEntryResponse, CLIEnvPayload } from "@/types/cli-credential";
@@ -27,6 +28,9 @@ interface UserCredEntry {
has_env: boolean;
/** Env variable names (no values) for display */
env_keys?: string[];
/** Phase 5: typed-credential adapter routing. */
credential_type?: string | null;
host_scope?: string | null;
created_at: string;
updated_at: string;
}
@@ -74,6 +78,16 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
const [saving, setSaving] = useState(false);
const [deleting, setDeletingId] = useState<string | null>(null);
// Phase 5 — git typed credential form state. Always declared, only
// exercised when binary.adapter_name === "git".
const isGit = binary.adapter_name === "git";
const [gitType, setGitType] = useState<GitCredentialType>("pat");
const [gitHostScope, setGitHostScope] = useState("");
const [gitToken, setGitToken] = useState("");
const [gitPrivateKey, setGitPrivateKey] = useState("");
const [gitErrorKey, setGitErrorKey] = useState<string | undefined>(undefined);
const [gitHasExistingSecret, setGitHasExistingSecret] = useState(false);
// User picker for the form
const loadList = useCallback(async () => {
@@ -97,14 +111,26 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
setUserId("");
setUserSearchText("");
setEnvEntries([]);
setGitType(isGit ? "pat" : "env");
setGitHostScope("");
setGitToken("");
setGitPrivateKey("");
setGitErrorKey(undefined);
setGitHasExistingSecret(false);
loadList();
}, [open, loadList]);
}, [open, loadList, isGit]);
const openAdd = () => {
setEditEntry(null);
setUserId("");
setUserSearchText("");
setEnvEntries([]);
setGitType(isGit ? "pat" : "env");
setGitHostScope("");
setGitToken("");
setGitPrivateKey("");
setGitErrorKey(undefined);
setGitHasExistingSecret(false);
setView("form");
};
@@ -113,21 +139,112 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
setUserId(entry.user_id);
setUserSearchText(entry.user_id);
setEnvEntries([]);
setGitErrorKey(undefined);
setView("form");
// Load existing env for edit
// Load existing data for edit. The GET response includes credential_type +
// host_scope but NEVER the secret blob — so we show "secret set" masked
// placeholder and require re-entry to change the secret.
try {
const res = await http.get<{ user_id: string; env: Record<string, CLIEnvEntryResponse> | null }>(
`/v1/cli-credentials/${binary.id}/user-credentials/${entry.user_id}`,
);
const res = await http.get<{
user_id: string;
env: Record<string, CLIEnvEntryResponse> | null;
credential_type?: string | null;
host_scope?: string | null;
has_secret?: boolean;
}>(`/v1/cli-credentials/${binary.id}/user-credentials/${entry.user_id}`);
setEnvEntries(entriesFromEnv(res.env));
if (isGit) {
const t = (res.credential_type ?? "env") as GitCredentialType;
setGitType(t === "pat" || t === "ssh_key" ? t : "env");
setGitHostScope(res.host_scope ?? "");
setGitHasExistingSecret(!!res.has_secret);
setGitToken("");
setGitPrivateKey("");
}
} catch {
// leave env empty — user can re-enter
// leave fields empty — user can re-enter
}
};
/** Build the PUT payload for the git typed-credential path.
* Returns null when the caller should fall through to the legacy env flow
* (gitType==="env" or non-git binary). Returns a string error message when
* client-side validation fails BEFORE the network round-trip — we still want
* inline UI on host_scope required to be instant. */
const buildGitTypedPayload = (): { credential_type: string; host_scope: string; blob: Record<string, string> } | null | string => {
if (!isGit || gitType === "env") return null;
const scope = gitHostScope.trim();
if (!scope) {
setGitErrorKey("git.cred_host_scope_required");
return "host_scope_required";
}
if (gitType === "pat") {
const tok = gitToken;
// On edit, allow empty token → caller should not submit (keeps existing secret).
// We block here because typed PUT replaces the blob entirely.
if (!tok) {
if (gitHasExistingSecret) return "no_change";
setGitErrorKey("git.cred_blob_missing_token");
return "token_required";
}
return { credential_type: "pat", host_scope: scope, blob: { token: tok } };
}
if (gitType === "ssh_key") {
const key = gitPrivateKey;
if (!key.trim()) {
if (gitHasExistingSecret) return "no_change";
setGitErrorKey("git.cred_blob_missing_key");
return "key_required";
}
return { credential_type: "ssh_key", host_scope: scope, blob: { key } };
}
return null;
};
const handleSave = async () => {
const uid = userId.trim();
if (!uid) return;
setGitErrorKey(undefined);
// Git typed branch — supersedes the legacy env path for pat/ssh_key.
if (isGit && gitType !== "env") {
const payload = buildGitTypedPayload();
if (typeof payload === "string") {
// Client-side validation failure already set gitErrorKey above; bail
// without toast so the inline field error is the single source of truth.
if (payload === "no_change") {
toast.success(i18next.t("cli-credentials:userCredentials.saved"));
setView("list");
}
return;
}
if (payload === null) return;
setSaving(true);
try {
await http.put(`/v1/cli-credentials/${binary.id}/user-credentials/${uid}`, payload);
toast.success(i18next.t("cli-credentials:userCredentials.saved"));
await loadList();
setView("list");
} catch (err) {
// Backend writes typed errors with `code = error_key` so the shared
// HttpClient surfaces them on err.code. Drive inline UI off that.
const code = (err as { code?: string })?.code;
if (code && code.startsWith("git.cred_")) {
setGitErrorKey(code);
} else {
toast.error(
i18next.t("cli-credentials:userCredentials.saveFailed"),
err instanceof Error ? err.message : "",
);
}
} finally {
setSaving(false);
}
return;
}
// Legacy env-vars path (passthrough binaries + git's "env" fallback).
const env = envPayloadFromEntries(envEntries);
// New entry needs at least one variable; edits may clear all keys (empty object).
if (!editEntry && Object.keys(env).length === 0) {
@@ -199,12 +316,25 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono text-sm truncate">{entry.user_id}</span>
{entry.has_env && (
{entry.credential_type && entry.credential_type !== "env" ? (
<Badge variant="default" className="shrink-0 text-xs uppercase">
{entry.credential_type === "pat"
? t("userCredentials.credentialTypePAT")
: entry.credential_type === "ssh_key"
? t("userCredentials.credentialTypeSSH")
: entry.credential_type}
</Badge>
) : entry.has_env ? (
<Badge variant="secondary" className="shrink-0 text-xs">
env
</Badge>
)}
) : null}
</div>
{entry.host_scope && (
<p className="text-xs text-muted-foreground font-mono truncate" title={entry.host_scope}>
{entry.host_scope}
</p>
)}
{entry.env_keys && entry.env_keys.length > 0 && (
<p className="text-xs text-muted-foreground font-mono truncate" title={entry.env_keys.join(", ")}>
{entry.env_keys.join(", ")}
@@ -266,17 +396,34 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 rounded-md px-2.5 py-1.5 border border-amber-200 dark:border-amber-800">{t("userCredentials.mergeHint")}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t("userCredentials.env")}</Label>
<CliCredentialEnvVarsSection
isManualMode
activePreset={null}
envValues={{}}
setEnvValues={() => undefined}
manualEnvEntries={envEntries}
setManualEnvEntries={setEnvEntries}
{isGit ? (
<CliCredentialGitFields
type={gitType}
onTypeChange={setGitType}
hostScope={gitHostScope}
onHostScopeChange={setGitHostScope}
token={gitToken}
onTokenChange={setGitToken}
privateKey={gitPrivateKey}
onPrivateKeyChange={setGitPrivateKey}
errorKey={gitErrorKey}
hasExistingSecret={gitHasExistingSecret}
/>
</div>
) : null}
{(!isGit || gitType === "env") && (
<div className="flex flex-col gap-1.5">
<Label>{t("userCredentials.env")}</Label>
<CliCredentialEnvVarsSection
isManualMode
activePreset={null}
envValues={{}}
setEnvValues={() => undefined}
manualEnvEntries={envEntries}
setManualEnvEntries={setEnvEntries}
/>
</div>
)}
</div>
<DialogFooter>
+5
View File
@@ -36,6 +36,11 @@ export interface SecureCLIBinary {
* Absent on older API versions — capability-probe: skip rendering if undefined.
*/
agent_grants_summary?: AgentGrantSummary[];
/**
* Adapter name routes per-user credentials through a typed flow.
* Phase 5: "git" → PAT/SSH form fields; absent/empty → legacy env-vars form.
*/
adapter_name?: string;
}
export interface CLIPresetEnvVar {