feat(cli): split env visibility for credentials

This commit is contained in:
Goon committed 2026-05-24 15:14:43 +07:00
1 parent 2bb736cf18
commit 3294fccbad
24 files changed
+944 -272

No files matched your search

+7
View File
@@ -372,6 +372,13 @@ Custom tools are shell-based tools defined at runtime via the HTTP API — no re
| `env` | no | Encrypted environment variables injected at runtime |
| `enabled` | no | Toggle without deleting (default true) |
Credentialed CLI env entries support two API/UI kinds:
- `sensitive` (default): encrypted at rest, masked in normal API responses, replace-only in UI, and flattened only at credential injection time.
- `value`: encrypted at rest but visible to authorized admins in API/UI for non-secret settings such as public URLs, domains, limits, regions, and feature flags.
Legacy env JSON like `{"TOKEN":"..."}` is still accepted and treated as `sensitive`.
**Execution:** Template placeholders are rendered with shell-escaped argument values, then run via `sh -c`. The same deny-pattern check as the `exec` tool applies — no reverse shells, no `curl | sh`, etc.
**Scope:**
+3
View File
@@ -221,11 +221,14 @@ AES-256-GCM encryption for secrets stored in PostgreSQL. Key provided via `GOCLA
| LLM provider API keys | `llm_providers` | `api_key` |
| MCP server API keys | `mcp_servers` | `api_key` |
| Custom tool env vars | `custom_tools` | `env` |
| Credentialed CLI env vars | `secure_cli_binaries`, `secure_cli_agent_grants`, `secure_cli_user_credentials` | `encrypted_env` |
**Format**: `"aes-gcm:" + base64(12-byte nonce + ciphertext + GCM tag)`
Backward compatible: values without the `aes-gcm:` prefix are returned as plaintext (for migration from unencrypted data).
Credentialed CLI env entries have a separate visibility kind inside the encrypted JSON blob when `GOCLAW_ENCRYPTION_KEY` is configured. `sensitive` entries are masked in normal API/UI responses and never returned raw except through the explicit audited grant reveal flow. `value` entries use the same at-rest storage path but are returned to authorized admins for operational review.
---
## 4. Rate Limiting -- Gateway + Tool
+17
View File
@@ -6,6 +6,23 @@ Significant changes, features, and fixes in reverse chronological order.
## 2026-05-24
### CLI environment variable visibility
**Features**
- Added `sensitive` and `value` kinds for secure CLI environment variables across binary defaults, agent grant overrides, and user overrides.
- Plain value entries are visible to authorized admins for operational config review, while sensitive entries remain masked and replace-only.
**Fixes**
- Stopped per-user credential reads from returning legacy sensitive env values raw.
- Kept legacy `{"KEY":"value"}` env blobs backward-compatible by treating them as sensitive.
**Tests**
- Added backend regression coverage for env kind parsing, sanitized API responses, runtime flattening, and invalid kind rejection.
- Verified Web UI build after adding env-kind controls and warnings.
### Command keyword allowlist
**Features**
+43 -99
View File
@@ -7,7 +7,6 @@ import (
"net/http"
"os/exec"
"regexp"
"sort"
"strings"
"github.com/google/uuid"
@@ -70,68 +69,13 @@ func (h *SecureCLIHandler) emitCacheInvalidate(key string) {
// envKeysFromDecryptedJSON returns sorted env variable names from plaintext env JSON (decrypted blob).
func envKeysFromDecryptedJSON(env []byte) []string {
empty := []string{}
if len(env) == 0 {
return empty
}
var m map[string]any
if err := json.Unmarshal(env, &m); err != nil {
return empty
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
return store.SecureCLIEnvKeys(env)
}
// mergeSecureCLIEnv merges incoming env from the UI with existing stored env.
// Incoming defines the full set of keys shown in the form: keys omitted were removed.
// Empty string means "keep existing value" for that key when it already exists.
func mergeSecureCLIEnv(existingJSON []byte, incoming map[string]any) (map[string]string, error) {
existing := map[string]string{}
if len(existingJSON) > 0 {
if err := json.Unmarshal(existingJSON, &existing); err != nil {
return nil, fmt.Errorf("parse existing env: %w", err)
}
}
out := make(map[string]string)
for k, v := range incoming {
if k == "" {
continue
}
sv, err := envValueAsString(v)
if err != nil {
return nil, fmt.Errorf("invalid environment variable value")
}
if sv != "" {
out[k] = sv
continue
}
if ev, ok := existing[k]; ok {
out[k] = ev
}
}
return out, nil
}
func envValueAsString(v any) (string, error) {
switch t := v.(type) {
case string:
return t, nil
case float64:
return fmt.Sprint(t), nil
case bool:
if t {
return "true", nil
}
return "false", nil
case nil:
return "", nil
default:
return "", fmt.Errorf("value must be a string")
}
func populateBinaryEnvResponse(b *store.SecureCLIBinary) {
b.EnvKeys = store.SecureCLIEnvKeys(b.EncryptedEnv)
b.Env = store.SanitizeSecureCLIEnvJSON(b.EncryptedEnv)
b.EncryptedEnv = nil
}
func (h *SecureCLIHandler) handleList(w http.ResponseWriter, r *http.Request) {
@@ -142,27 +86,26 @@ func (h *SecureCLIHandler) handleList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "CLI credentials")})
return
}
// Never send env values; only variable names for editing.
// Sensitive env values stay masked; value-kind entries are returned for editing.
for i := range result {
result[i].EnvKeys = envKeysFromDecryptedJSON(result[i].EncryptedEnv)
result[i].EncryptedEnv = nil
populateBinaryEnvResponse(&result[i])
}
writeJSON(w, http.StatusOK, map[string]any{"items": result})
}
// secureCLICreateRequest supports both preset-based and custom creation.
type secureCLICreateRequest struct {
Preset string `json:"preset,omitempty"` // auto-fill from preset
BinaryName string `json:"binary_name"`
BinaryPath *string `json:"binary_path,omitempty"`
Description string `json:"description"`
Env map[string]string `json:"env"` // plaintext env vars (encrypted by store)
DenyArgs json.RawMessage `json:"deny_args,omitempty"`
DenyVerbose json.RawMessage `json:"deny_verbose,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
Tips string `json:"tips,omitempty"`
IsGlobal *bool `json:"is_global,omitempty"`
Enabled bool `json:"enabled"`
Preset string `json:"preset,omitempty"` // auto-fill from preset
BinaryName string `json:"binary_name"`
BinaryPath *string `json:"binary_path,omitempty"`
Description string `json:"description"`
Env json.RawMessage `json:"env"` // plaintext env vars or env entry objects (encrypted by store)
DenyArgs json.RawMessage `json:"deny_args,omitempty"`
DenyVerbose json.RawMessage `json:"deny_verbose,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
Tips string `json:"tips,omitempty"`
IsGlobal *bool `json:"is_global,omitempty"`
Enabled bool `json:"enabled"`
}
func (h *SecureCLIHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
@@ -209,10 +152,14 @@ func (h *SecureCLIHandler) handleCreate(w http.ResponseWriter, r *http.Request)
return
}
// Serialize env as JSON bytes (store layer encrypts)
envJSON, err := json.Marshal(req.Env)
envEntries, err := store.ParseSecureCLIEnv(req.Env)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid env"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgGrantEnvValueInvalid, err.Error())})
return
}
envJSON, err := store.SerializeSecureCLIEnv(envEntries)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgGrantEnvValueInvalid, err.Error())})
return
}
@@ -240,7 +187,7 @@ func (h *SecureCLIHandler) handleCreate(w http.ResponseWriter, r *http.Request)
}
tools.ResetCredentialScrubValues() // clear stale scrub values
b.EncryptedEnv = nil // don't return credentials
populateBinaryEnvResponse(b)
emitAudit(h.msgBus, r, "secure_cli.created", "secure_cli", b.ID.String())
h.emitCacheInvalidate(b.ID.String())
writeJSON(w, http.StatusCreated, b)
@@ -260,8 +207,7 @@ func (h *SecureCLIHandler) handleGet(w http.ResponseWriter, r *http.Request) {
return
}
b.EnvKeys = envKeysFromDecryptedJSON(b.EncryptedEnv)
b.EncryptedEnv = nil // don't expose credential values
populateBinaryEnvResponse(b)
writeJSON(w, http.StatusOK, b)
}
@@ -293,25 +239,23 @@ func (h *SecureCLIHandler) handleUpdate(w http.ResponseWriter, r *http.Request)
// If env is updated, merge with stored env so empty values mean "keep existing secret".
if envVal, ok := updates["env"]; ok {
if envMap, isMap := envVal.(map[string]any); isMap {
cur, err := h.store.Get(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "credential", id.String())})
return
}
merged, err := mergeSecureCLIEnv(cur.EncryptedEnv, envMap)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
envJSON, err := json.Marshal(merged)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid env"})
return
}
updates["encrypted_env"] = string(envJSON)
delete(updates, "env")
envJSON, err := json.Marshal(envVal)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgGrantEnvValueInvalid, err.Error())})
return
}
cur, err := h.store.Get(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "credential", id.String())})
return
}
merged, err := store.MergeSecureCLIEnv(cur.EncryptedEnv, envJSON)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgGrantEnvValueInvalid, err.Error())})
return
}
updates["encrypted_env"] = string(merged)
delete(updates, "env")
}
if err := h.store.Update(r.Context(), id, updates); err != nil {
+38 -38
View File
@@ -68,7 +68,7 @@ func (h *SecureCLIGrantHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/cli-credentials/{id}/agent-grants/{grantId}", auth(h.handleGet))
mux.HandleFunc("PUT /v1/cli-credentials/{id}/agent-grants/{grantId}", auth(h.handleUpdate))
mux.HandleFunc("DELETE /v1/cli-credentials/{id}/agent-grants/{grantId}", auth(h.handleDelete))
// POST (not GET) to prevent caching and satisfy CSRF semantics per Red Team C1.
// POST keeps revealed secret material out of URL/history and avoids query caching.
mux.HandleFunc("POST /v1/cli-credentials/{id}/agent-grants/{grantId}/env:reveal", auth(h.handleRevealEnv))
}
@@ -76,45 +76,41 @@ func (h *SecureCLIGrantHandler) RegisterRoutes(mux *http.ServeMux) {
// EnvVars is optional; plaintext values are encrypted by the store layer.
// Clients MUST NOT send encrypted_env — that field is never accepted from the wire.
type grantCreateRequest struct {
AgentID uuid.UUID `json:"agent_id"`
EnvVars map[string]string `json:"env_vars,omitempty"`
DenyArgs *json.RawMessage `json:"deny_args,omitempty"`
DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
Tips *string `json:"tips,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
AgentID uuid.UUID `json:"agent_id"`
EnvVars json.RawMessage `json:"env_vars,omitempty"`
DenyArgs *json.RawMessage `json:"deny_args,omitempty"`
DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
Tips *string `json:"tips,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// populateGrantEnvFields sets EnvKeys (sorted) and EnvSet from the grant's decrypted env bytes.
// Plaintext values are never exposed — only key names.
// populateGrantEnvFields sets sorted key names, env presence, and sanitized entries.
func populateGrantEnvFields(g *store.SecureCLIAgentGrant) {
if len(g.EncryptedEnv) == 0 {
g.EnvKeys = []string{}
g.Env = nil
g.EnvSet = false
return
}
var m map[string]any
if err := json.Unmarshal(g.EncryptedEnv, &m); err != nil {
g.EnvKeys = []string{}
g.EnvSet = false
return
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
keys := store.SecureCLIEnvKeys(g.EncryptedEnv)
g.EnvKeys = keys
g.Env = store.SanitizeSecureCLIEnvJSON(g.EncryptedEnv)
g.EnvSet = len(keys) > 0
}
// validateAndSerializeEnvVars validates env keys/values via denylist and returns serialized JSON.
// Returns (nil, 400 error response written) on denial, (jsonBytes, nil) on success.
// Never logs env values or keys in error paths.
func validateAndSerializeEnvVars(w http.ResponseWriter, locale string, envVars map[string]string) ([]byte, bool) {
if len(envVars) == 0 {
b, _ := json.Marshal(envVars)
return b, true
func validateAndSerializeEnvVars(w http.ResponseWriter, locale string, raw json.RawMessage) ([]byte, bool) {
envEntries, err := store.ParseSecureCLIEnv(raw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgGrantEnvValueInvalid, err.Error())})
return nil, false
}
envVars := make(map[string]string, len(envEntries))
for key, entry := range envEntries {
envVars[key] = entry.Value
}
denied, valErr := crypto.ValidateGrantEnvVars(envVars)
if valErr != nil {
@@ -129,7 +125,7 @@ func validateAndSerializeEnvVars(w http.ResponseWriter, locale string, envVars m
})
return nil, false
}
b, err := json.Marshal(envVars)
b, err := store.SerializeSecureCLIEnv(envEntries)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgGrantEnvValueInvalid, "serialization failed")})
return nil, false
@@ -249,7 +245,7 @@ func (h *SecureCLIGrantHandler) handleCreate(w http.ResponseWriter, r *http.Requ
envJSON, ok := validateAndSerializeEnvVars(w, locale, req.EnvVars)
if !ok {
// Grant was created but env validation failed; clean it up to avoid orphan row.
// Finding #13: log rollback-delete failures for ops visibility.
// Log rollback-delete failures so operators can clean up orphan rows.
if delErr := h.grants.Delete(r.Context(), g.ID); delErr != nil {
slog.Error("secure_cli_grants.create.rollback_delete",
"grant_id", g.ID,
@@ -261,7 +257,7 @@ func (h *SecureCLIGrantHandler) handleCreate(w http.ResponseWriter, r *http.Requ
}
if err := h.grants.UpdateGrantEnv(r.Context(), g.ID, envJSON); err != nil {
slog.Error("secure_cli_grants.create.set_env", "grant_id", g.ID, "error", err)
// Finding #13: log rollback-delete failures for ops visibility.
// Log rollback-delete failures so operators can clean up orphan rows.
if delErr := h.grants.Delete(r.Context(), g.ID); delErr != nil {
slog.Error("secure_cli_grants.create.rollback_delete",
"grant_id", g.ID,
@@ -323,7 +319,7 @@ func (h *SecureCLIGrantHandler) handleUpdate(w http.ResponseWriter, r *http.Requ
}
if allowedScalar[k] {
var decoded any
// Finding #3: return 400 on Unmarshal failure — silent discard means admin
// Return 400 on unmarshal failure; silent discard means admin
// thinks they applied a change (e.g. enabled: "false") but the grant is unchanged.
if err := json.Unmarshal(v, &decoded); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
@@ -335,7 +331,7 @@ func (h *SecureCLIGrantHandler) handleUpdate(w http.ResponseWriter, r *http.Requ
}
}
// 3-state env_vars semantics: absent=skip, null=clear, {...}=replace.
// Finding #15: {} (empty map) is treated as clear — same as null.
// Empty map is treated as clear, same as null.
// TS type: absent | null | Record<string,string> — see ui/web/src/types/cli-credential.ts.
var envJSON []byte
envPresent := false
@@ -343,18 +339,22 @@ func (h *SecureCLIGrantHandler) handleUpdate(w http.ResponseWriter, r *http.Requ
envPresent = true
var envPtr *map[string]string
if string(envRaw) != "null" {
var m map[string]string
if err := json.Unmarshal(envRaw, &m); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgGrantEnvValueInvalid, "env_vars must be a string map")})
envEntries, err := store.ParseSecureCLIEnv(envRaw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgGrantEnvValueInvalid, "env_vars must be a string map or env entries")})
return
}
m := make(map[string]string, len(envEntries))
for key, entry := range envEntries {
m[key] = entry.Value
}
envPtr = &m
}
// envPtr == nil → clear; envPtr != nil → replace.
// Note: envPtr pointing to an empty map ({}) is treated as clear (same as null) —
// envJSON stays nil and UpdateGrantEnv(nil) removes the override.
if envPtr != nil && len(*envPtr) > 0 {
j, ok := validateAndSerializeEnvVars(w, locale, *envPtr)
j, ok := validateAndSerializeEnvVars(w, locale, envRaw)
if !ok {
return
}
@@ -430,8 +430,8 @@ func (h *SecureCLIGrantHandler) handleRevealEnv(w http.ResponseWriter, r *http.R
locale := store.LocaleFromContext(ctx)
// Rate limit: 10 reveals/min per authenticated caller (context UserID).
// Finding #2: require non-empty UserID from authenticated context.
// If UserID is empty, the auth middleware failed to populate it — reject rather
// Require non-empty UserID from authenticated context.
// If UserID is empty, auth middleware failed to populate it — reject rather
// than fall back to a spoofable header or IP address.
callerID := store.UserIDFromContext(ctx)
if callerID == "" {
@@ -476,8 +476,8 @@ func (h *SecureCLIGrantHandler) handleRevealEnv(w http.ResponseWriter, r *http.R
writeJSON(w, http.StatusOK, map[string]any{"env_vars": map[string]string{}})
return
}
var envVars map[string]string
if err := json.Unmarshal(g.EncryptedEnv, &envVars); err != nil {
envVars, err := store.FlattenSecureCLIEnv(g.EncryptedEnv)
if err != nil {
slog.Error("secure_cli_grants.reveal.parse", "grant_id", g.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "parse grant env")})
return
+50 -2
View File
@@ -3,6 +3,7 @@ package http
import (
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
@@ -59,8 +60,16 @@ func (s *fakeSecureCLIGrantStore) Delete(context.Context, uuid.UUID) error {
return nil
}
func (s *fakeSecureCLIGrantStore) ListByBinary(context.Context, uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
return nil, nil
func (s *fakeSecureCLIGrantStore) ListByBinary(_ context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
grants := make([]store.SecureCLIAgentGrant, 0, len(s.grants))
for _, grant := range s.grants {
if grant == nil || grant.BinaryID != binaryID {
continue
}
cp := *grant
grants = append(grants, cp)
}
return grants, nil
}
func (s *fakeSecureCLIGrantStore) ListByAgent(context.Context, uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
@@ -202,3 +211,42 @@ func TestSecureCLIGrantUpdateRejectsInvalidEnvVarsBeforeScalarUpdate(t *testing.
t.Fatal("invalid env_vars request must not persist scalar grant updates")
}
}
func TestSecureCLIGrantGetSanitizesMixedEnv(t *testing.T) {
binaryID := uuid.New()
grantID := uuid.New()
fake := &fakeSecureCLIGrantStore{
grants: map[uuid.UUID]*store.SecureCLIAgentGrant{
grantID: {
BaseModel: store.BaseModel{ID: grantID},
BinaryID: binaryID,
AgentID: uuid.New(),
Enabled: true,
EncryptedEnv: []byte(`{"TOKEN":"secret-token","PUBLIC_BASE_URL":{"kind":"value","value":"https://goclaw.sh"}}`),
},
},
}
h := NewSecureCLIGrantHandler(fake, nil, nil)
rr, req := requestWithGrantPath(http.MethodGet, nil, binaryID, grantID)
h.handleGet(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
}
if strings.Contains(rr.Body.String(), "secret-token") {
t.Fatalf("sensitive grant env leaked in response: %s", rr.Body.String())
}
var got struct {
Env map[string]store.SecureCLIEnvResponseEntry `json:"env"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Env["TOKEN"].Masked || got.Env["TOKEN"].Value != nil {
t.Fatalf("TOKEN not masked: %#v", got.Env["TOKEN"])
}
if got.Env["PUBLIC_BASE_URL"].Value == nil || *got.Env["PUBLIC_BASE_URL"].Value != "https://goclaw.sh" {
t.Fatalf("PUBLIC_BASE_URL not returned: %#v", got.Env["PUBLIC_BASE_URL"])
}
}
@@ -0,0 +1,129 @@
package http
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type fakeSecureCLIStore struct {
binary *store.SecureCLIBinary
user *store.SecureCLIUserCredential
}
func (s *fakeSecureCLIStore) Create(context.Context, *store.SecureCLIBinary) error { return nil }
func (s *fakeSecureCLIStore) Get(context.Context, uuid.UUID) (*store.SecureCLIBinary, error) {
cp := *s.binary
return &cp, nil
}
func (s *fakeSecureCLIStore) Update(context.Context, uuid.UUID, map[string]any) error { return nil }
func (s *fakeSecureCLIStore) Delete(context.Context, uuid.UUID) error { return nil }
func (s *fakeSecureCLIStore) List(context.Context) ([]store.SecureCLIBinary, error) {
cp := *s.binary
return []store.SecureCLIBinary{cp}, nil
}
func (s *fakeSecureCLIStore) LookupByBinary(context.Context, string, *uuid.UUID, string) (*store.SecureCLIBinary, error) {
return nil, nil
}
func (s *fakeSecureCLIStore) ListEnabled(context.Context) ([]store.SecureCLIBinary, error) {
return nil, nil
}
func (s *fakeSecureCLIStore) ListForAgent(context.Context, uuid.UUID) ([]store.SecureCLIBinary, error) {
return nil, nil
}
func (s *fakeSecureCLIStore) IsRegisteredBinary(context.Context, string) (bool, error) {
return false, nil
}
func (s *fakeSecureCLIStore) GetUserCredentials(context.Context, uuid.UUID, string) (*store.SecureCLIUserCredential, error) {
cp := *s.user
return &cp, nil
}
func (s *fakeSecureCLIStore) SetUserCredentials(context.Context, uuid.UUID, string, []byte) error {
return nil
}
func (s *fakeSecureCLIStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error {
return nil
}
func (s *fakeSecureCLIStore) ListUserCredentials(context.Context, uuid.UUID) ([]store.SecureCLIUserCredential, error) {
cp := *s.user
return []store.SecureCLIUserCredential{cp}, nil
}
func TestSecureCLIGetSanitizesMixedEnv(t *testing.T) {
id := uuid.New()
h := NewSecureCLIHandler(&fakeSecureCLIStore{
binary: &store.SecureCLIBinary{
BaseModel: store.BaseModel{ID: id},
BinaryName: "gh",
EncryptedEnv: []byte(`{"TOKEN":"secret-token","PUBLIC_BASE_URL":{"kind":"value","value":"https://goclaw.sh"}}`),
},
}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/cli-credentials/"+id.String(), nil)
req.SetPathValue("id", id.String())
rec := httptest.NewRecorder()
h.handleGet(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "secret-token") {
t.Fatalf("sensitive env leaked in response: %s", rec.Body.String())
}
var got struct {
Env map[string]store.SecureCLIEnvResponseEntry `json:"env"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Env["TOKEN"].Masked || got.Env["TOKEN"].Value != nil {
t.Fatalf("TOKEN not masked: %#v", got.Env["TOKEN"])
}
if got.Env["PUBLIC_BASE_URL"].Value == nil || *got.Env["PUBLIC_BASE_URL"].Value != "https://goclaw.sh" {
t.Fatalf("value env not returned: %#v", got.Env["PUBLIC_BASE_URL"])
}
}
func TestSecureCLIUserCredentialsGetDoesNotReturnLegacySensitiveRaw(t *testing.T) {
binaryID := uuid.New()
h := NewSecureCLIHandler(&fakeSecureCLIStore{
user: &store.SecureCLIUserCredential{
ID: uuid.New(),
BinaryID: binaryID,
UserID: "user-1",
EncryptedEnv: []byte(`{"TOKEN":"secret-token","REGION":{"kind":"value","value":"asia-southeast1"}}`),
},
}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/cli-credentials/"+binaryID.String()+"/user-credentials/user-1", nil)
req.SetPathValue("id", binaryID.String())
req.SetPathValue("userId", "user-1")
rec := httptest.NewRecorder()
h.handleGetUserCredentials(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "secret-token") {
t.Fatalf("legacy sensitive env leaked in response: %s", rec.Body.String())
}
var got struct {
Env map[string]store.SecureCLIEnvResponseEntry `json:"env"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Env["TOKEN"].Masked || got.Env["TOKEN"].Value != nil {
t.Fatalf("TOKEN not masked: %#v", got.Env["TOKEN"])
}
if got.Env["REGION"].Value == nil || *got.Env["REGION"].Value != "asia-southeast1" {
t.Fatalf("REGION not returned: %#v", got.Env["REGION"])
}
}
+24 -18
View File
@@ -25,13 +25,14 @@ func (h *SecureCLIHandler) handleListUserCredentials(w http.ResponseWriter, r *h
}
// Return without env values for listing (names only + timestamps)
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"`
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"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
entries := make([]entry, 0, len(creds))
for _, c := range creds {
@@ -42,6 +43,7 @@ func (h *SecureCLIHandler) handleListUserCredentials(w http.ResponseWriter, r *h
UserID: c.UserID,
HasEnv: len(c.EncryptedEnv) > 0,
EnvKeys: envKeys,
Env: store.SanitizeSecureCLIEnvJSON(c.EncryptedEnv),
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
})
@@ -73,14 +75,9 @@ func (h *SecureCLIHandler) handleGetUserCredentials(w http.ResponseWriter, r *ht
return
}
// Return decrypted env as JSON object (admin-only endpoint)
var envObj any
if len(cred.EncryptedEnv) > 0 {
_ = json.Unmarshal(cred.EncryptedEnv, &envObj)
}
writeJSON(w, http.StatusOK, map[string]any{
"user_id": cred.UserID,
"env": envObj,
"env": store.SanitizeSecureCLIEnvJSON(cred.EncryptedEnv),
})
}
@@ -108,14 +105,23 @@ func (h *SecureCLIHandler) handleSetUserCredentials(w http.ResponseWriter, r *ht
return
}
// Validate env is a JSON object
var envCheck map[string]string
if err := json.Unmarshal(body.Env, &envCheck); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "env must be a JSON object with string values"})
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
}
var existingEnv []byte
if existing != nil {
existingEnv = existing.EncryptedEnv
}
envJSON, err := store.MergeSecureCLIEnv(existingEnv, body.Env)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgGrantEnvValueInvalid, err.Error())})
return
}
if err := h.store.SetUserCredentials(r.Context(), binaryID, userID, body.Env); err != nil {
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
+200
View File
@@ -0,0 +1,200 @@
package store
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"strings"
)
const (
SecureCLIEnvKindSensitive = "sensitive"
SecureCLIEnvKindValue = "value"
)
// SecureCLIEnvEntry is the stored per-key env representation; legacy KEY:string maps decode as sensitive.
type SecureCLIEnvEntry struct {
Kind string `json:"kind"`
Value string `json:"value"`
}
// SecureCLIEnvResponseEntry is safe to serialize in admin API responses.
type SecureCLIEnvResponseEntry struct {
Kind string `json:"kind"`
Value *string `json:"value"`
Masked bool `json:"masked"`
}
func ParseSecureCLIEnv(raw []byte) (map[string]SecureCLIEnvEntry, error) {
if len(bytes.TrimSpace(raw)) == 0 {
return map[string]SecureCLIEnvEntry{}, nil
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
env := make(map[string]SecureCLIEnvEntry, len(payload))
for key, item := range payload {
key = strings.TrimSpace(key)
if key == "" {
continue
}
entry, err := parseSecureCLIEnvEntry(item)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
env[key] = entry
}
return env, nil
}
func parseSecureCLIEnvEntry(raw json.RawMessage) (SecureCLIEnvEntry, error) {
var legacy string
if err := json.Unmarshal(raw, &legacy); err == nil {
return SecureCLIEnvEntry{Kind: SecureCLIEnvKindSensitive, Value: legacy}, nil
}
trimmed := bytes.TrimSpace(raw)
if len(trimmed) > 0 && trimmed[0] != '{' {
value, err := secureCLIEnvValueAsString(raw)
if err != nil {
return SecureCLIEnvEntry{}, err
}
return SecureCLIEnvEntry{Kind: SecureCLIEnvKindSensitive, Value: value}, nil
}
var obj struct {
Kind string `json:"kind"`
Value json.RawMessage `json:"value"`
}
if err := json.Unmarshal(raw, &obj); err != nil {
return SecureCLIEnvEntry{}, err
}
kind := strings.ToLower(strings.TrimSpace(obj.Kind))
if kind == "" {
kind = SecureCLIEnvKindSensitive
}
if kind != SecureCLIEnvKindSensitive && kind != SecureCLIEnvKindValue {
return SecureCLIEnvEntry{}, fmt.Errorf("invalid env kind %q", obj.Kind)
}
value, err := secureCLIEnvValueAsString(obj.Value)
if err != nil {
return SecureCLIEnvEntry{}, err
}
return SecureCLIEnvEntry{Kind: kind, Value: value}, nil
}
func secureCLIEnvValueAsString(raw json.RawMessage) (string, error) {
if len(bytes.TrimSpace(raw)) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return "", nil
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s, nil
}
var b bool
if err := json.Unmarshal(raw, &b); err == nil {
if b {
return "true", nil
}
return "false", nil
}
var f float64
if err := json.Unmarshal(raw, &f); err == nil {
return fmt.Sprint(f), nil
}
return "", fmt.Errorf("env value must be string, number, bool, or null")
}
func SerializeSecureCLIEnv(env map[string]SecureCLIEnvEntry) ([]byte, error) {
normalized := make(map[string]SecureCLIEnvEntry, len(env))
for key, entry := range env {
key = strings.TrimSpace(key)
if key == "" {
continue
}
kind := strings.ToLower(strings.TrimSpace(entry.Kind))
if kind == "" {
kind = SecureCLIEnvKindSensitive
}
if kind != SecureCLIEnvKindSensitive && kind != SecureCLIEnvKindValue {
return nil, fmt.Errorf("%s: invalid env kind %q", key, entry.Kind)
}
normalized[key] = SecureCLIEnvEntry{Kind: kind, Value: entry.Value}
}
return json.Marshal(normalized)
}
func FlattenSecureCLIEnv(raw []byte) (map[string]string, error) {
entries, err := ParseSecureCLIEnv(raw)
if err != nil {
return nil, err
}
flat := make(map[string]string, len(entries))
for key, entry := range entries {
flat[key] = entry.Value
}
return flat, nil
}
func MergeSecureCLIEnv(existingJSON []byte, incoming json.RawMessage) ([]byte, error) {
existing, err := ParseSecureCLIEnv(existingJSON)
if err != nil {
return nil, fmt.Errorf("parse existing env: %w", err)
}
incomingEntries, err := ParseSecureCLIEnv(incoming)
if err != nil {
return nil, fmt.Errorf("parse incoming env: %w", err)
}
out := make(map[string]SecureCLIEnvEntry, len(incomingEntries))
for key, entry := range incomingEntries {
if entry.Kind == SecureCLIEnvKindSensitive && entry.Value == "" {
if prev, ok := existing[key]; ok {
prev.Kind = SecureCLIEnvKindSensitive
out[key] = prev
continue
}
}
out[key] = entry
}
return SerializeSecureCLIEnv(out)
}
func SecureCLIEnvKeys(raw []byte) []string {
env, err := ParseSecureCLIEnv(raw)
if err != nil {
return []string{}
}
keys := make([]string, 0, len(env))
for key := range env {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func SanitizeSecureCLIEnv(env map[string]SecureCLIEnvEntry) map[string]SecureCLIEnvResponseEntry {
out := make(map[string]SecureCLIEnvResponseEntry, len(env))
for key, entry := range env {
if entry.Kind == SecureCLIEnvKindValue {
value := entry.Value
out[key] = SecureCLIEnvResponseEntry{Kind: SecureCLIEnvKindValue, Value: &value, Masked: false}
continue
}
out[key] = SecureCLIEnvResponseEntry{Kind: SecureCLIEnvKindSensitive, Value: nil, Masked: true}
}
return out
}
func SanitizeSecureCLIEnvJSON(raw []byte) map[string]SecureCLIEnvResponseEntry {
env, err := ParseSecureCLIEnv(raw)
if err != nil {
return map[string]SecureCLIEnvResponseEntry{}
}
return SanitizeSecureCLIEnv(env)
}
+102
View File
@@ -0,0 +1,102 @@
package store
import (
"encoding/json"
"testing"
)
func TestParseSecureCLIEnvLegacyMapDefaultsSensitive(t *testing.T) {
env, err := ParseSecureCLIEnv([]byte(`{"TOKEN":"secret","PUBLIC_BASE_URL":"https://goclaw.sh"}`))
if err != nil {
t.Fatalf("ParseSecureCLIEnv() error = %v", err)
}
if got := env["TOKEN"].Kind; got != SecureCLIEnvKindSensitive {
t.Fatalf("TOKEN kind = %q, want %q", got, SecureCLIEnvKindSensitive)
}
if got := env["TOKEN"].Value; got != "secret" {
t.Fatalf("TOKEN value = %q", got)
}
if got := env["PUBLIC_BASE_URL"].Kind; got != SecureCLIEnvKindSensitive {
t.Fatalf("PUBLIC_BASE_URL kind = %q, want default sensitive", got)
}
}
func TestParseSecureCLIEnvLegacyScalarsDefaultSensitive(t *testing.T) {
env, err := ParseSecureCLIEnv([]byte(`{"MAX_UPLOAD_SIZE_MB":100,"DEBUG":true}`))
if err != nil {
t.Fatalf("ParseSecureCLIEnv() error = %v", err)
}
if got := env["MAX_UPLOAD_SIZE_MB"]; got.Kind != SecureCLIEnvKindSensitive || got.Value != "100" {
t.Fatalf("MAX_UPLOAD_SIZE_MB = %#v, want sensitive 100", got)
}
if got := env["DEBUG"]; got.Kind != SecureCLIEnvKindSensitive || got.Value != "true" {
t.Fatalf("DEBUG = %#v, want sensitive true", got)
}
}
func TestSanitizeSecureCLIEnvMasksSensitiveAndReturnsValues(t *testing.T) {
env := map[string]SecureCLIEnvEntry{
"TOKEN": {Kind: SecureCLIEnvKindSensitive, Value: "secret"},
"PUBLIC_BASE_URL": {Kind: SecureCLIEnvKindValue, Value: "https://goclaw.sh"},
}
got := SanitizeSecureCLIEnv(env)
if got["TOKEN"].Value != nil {
t.Fatalf("sensitive value leaked: %q", *got["TOKEN"].Value)
}
if !got["TOKEN"].Masked {
t.Fatalf("sensitive masked = false")
}
if got["PUBLIC_BASE_URL"].Value == nil || *got["PUBLIC_BASE_URL"].Value != "https://goclaw.sh" {
t.Fatalf("value entry not returned: %#v", got["PUBLIC_BASE_URL"])
}
if got["PUBLIC_BASE_URL"].Masked {
t.Fatalf("value entry masked = true")
}
}
func TestMergeSecureCLIEnvPreservesExistingSensitiveOnEmptyValue(t *testing.T) {
existing := []byte(`{"TOKEN":{"kind":"sensitive","value":"old"},"PUBLIC_BASE_URL":{"kind":"value","value":"https://old.example"}}`)
incoming := json.RawMessage(`{"TOKEN":{"kind":"sensitive","value":""},"PUBLIC_BASE_URL":{"kind":"value","value":"https://new.example"}}`)
merged, err := MergeSecureCLIEnv(existing, incoming)
if err != nil {
t.Fatalf("MergeSecureCLIEnv() error = %v", err)
}
env, err := ParseSecureCLIEnv(merged)
if err != nil {
t.Fatalf("ParseSecureCLIEnv(merged) error = %v", err)
}
if got := env["TOKEN"].Value; got != "old" {
t.Fatalf("TOKEN value = %q, want preserved old", got)
}
if got := env["PUBLIC_BASE_URL"].Value; got != "https://new.example" {
t.Fatalf("PUBLIC_BASE_URL = %q", got)
}
if got := env["PUBLIC_BASE_URL"].Kind; got != SecureCLIEnvKindValue {
t.Fatalf("PUBLIC_BASE_URL kind = %q", got)
}
}
func TestFlattenSecureCLIEnvSupportsEntryShape(t *testing.T) {
got, err := FlattenSecureCLIEnv([]byte(`{
"TOKEN":{"kind":"sensitive","value":"secret"},
"PUBLIC_BASE_URL":{"kind":"value","value":"https://goclaw.sh"}
}`))
if err != nil {
t.Fatalf("FlattenSecureCLIEnv() error = %v", err)
}
if got["TOKEN"] != "secret" {
t.Fatalf("TOKEN = %q", got["TOKEN"])
}
if got["PUBLIC_BASE_URL"] != "https://goclaw.sh" {
t.Fatalf("PUBLIC_BASE_URL = %q", got["PUBLIC_BASE_URL"])
}
}
func TestParseSecureCLIEnvRejectsInvalidKind(t *testing.T) {
_, err := ParseSecureCLIEnv([]byte(`{"TOKEN":{"kind":"plain","value":"secret"}}`))
if err == nil {
t.Fatalf("ParseSecureCLIEnv() error = nil, want invalid kind error")
}
}
+4
View File
@@ -37,6 +37,8 @@ type SecureCLIBinary struct {
UserEnv []byte `json:"-" db:"-"` // per-user encrypted env (populated by LookupByBinary LEFT JOIN)
// 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.
Env map[string]SecureCLIEnvResponseEntry `json:"env,omitempty" db:"-"`
// AgentGrantsSummary is populated by List only — lightweight per-grant summary (no env bytes).
AgentGrantsSummary []AgentGrantSummary `json:"agent_grants_summary" db:"-"`
}
@@ -92,6 +94,8 @@ type SecureCLIAgentGrant struct {
EncryptedEnv []byte `json:"-" db:"encrypted_env"`
// EnvKeys is populated by HTTP handlers only (sorted key names, no values). Not a DB column.
EnvKeys []string `json:"env_keys,omitempty" db:"-"`
// Env is populated by HTTP handlers only for sanitized responses.
Env map[string]SecureCLIEnvResponseEntry `json:"env,omitempty" db:"-"`
// EnvSet indicates whether this grant has an env override. Not a DB column.
EnvSet bool `json:"env_set" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
+8 -6
View File
@@ -39,8 +39,8 @@ var wrapperBinaries = map[string]bool{
// normalizeBinaryName returns the lowercased file base of a binary reference.
// Examples: "/usr/bin/gh" → "gh", "./GH" → "gh", " Gh " → "gh".
// Applied at BOTH the gate lookup and lookupCredentialedBinary so the two
// layers agree on identity. (Red Team F5)
// Applied at both the gate lookup and lookupCredentialedBinary so the two
// layers agree on identity.
func normalizeBinaryName(s string) string {
return filepath.Base(strings.TrimSpace(strings.ToLower(s)))
}
@@ -436,13 +436,15 @@ func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error
return envMap, nil
}
if len(cred.EncryptedEnv) > 0 {
if err := json.Unmarshal(cred.EncryptedEnv, &envMap); err != nil {
baseEnv, err := store.FlattenSecureCLIEnv(cred.EncryptedEnv)
if err != nil {
return nil, err
}
maps.Copy(envMap, baseEnv)
}
if len(cred.UserEnv) > 0 {
var userEnvMap map[string]string
if err := json.Unmarshal(cred.UserEnv, &userEnvMap); err != nil {
userEnvMap, err := store.FlattenSecureCLIEnv(cred.UserEnv)
if err != nil {
return nil, err
}
maps.Copy(envMap, userEnvMap)
@@ -628,7 +630,7 @@ func (t *ExecTool) lookupCredentialedBinary(ctx context.Context, command string)
}
// Normalize lookup key so path/case variants (/usr/bin/gh, ./gh, GH) all
// resolve to the same registry row. Same helper is used by the gate
// branch in Execute — identity must agree at both layers. (Red Team F5)
// branch in Execute because identity must agree at both layers.
normBinary := normalizeBinaryName(binary)
// Get agent ID from context for scoped lookup
agentID := store.AgentIDFromContext(ctx)
@@ -43,3 +43,24 @@ func TestMergeCredentialedEnvFailsClosedOnInvalidUserEnv(t *testing.T) {
t.Fatal("expected invalid per-user env JSON to fail closed")
}
}
func TestMergeCredentialedEnvFlattensSensitiveValueEntries(t *testing.T) {
binary := &store.SecureCLIBinary{
EncryptedEnv: []byte(`{
"TOKEN":{"kind":"sensitive","value":"secret"},
"PUBLIC_BASE_URL":{"kind":"value","value":"https://goclaw.sh"}
}`),
UserEnv: []byte(`{"PUBLIC_BASE_URL":{"kind":"value","value":"https://user.example"}}`),
}
env, err := mergeCredentialedEnv(binary)
if err != nil {
t.Fatalf("mergeCredentialedEnv() error = %v", err)
}
if env["TOKEN"] != "secret" {
t.Fatalf("TOKEN = %q", env["TOKEN"])
}
if env["PUBLIC_BASE_URL"] != "https://user.example" {
t.Fatalf("PUBLIC_BASE_URL = %q", env["PUBLIC_BASE_URL"])
}
}
@@ -39,6 +39,10 @@
"noEnvVarsHint": "Click \"Add Variable\" to define environment variables for this CLI tool.",
"envKeyPlaceholder": "ENV_VAR_NAME",
"envValuePlaceholder": "value",
"envKindSensitive": "Sensitive",
"envKindValue": "Value",
"envValueWarning": "Plaintext values are visible to authorized users.",
"envValueSuspicious": "This plaintext value looks like a token, password, or key.",
"invalidEnvKey": "\"{{key}}\" is not a valid env variable name (use A-Z, 0-9, underscore).",
"binaryPathHint": "Leave blank to auto-detect from PATH",
"checkBinary": "Check",
@@ -39,6 +39,10 @@
"noEnvVarsHint": "Nhấn \"Thêm biến\" để khai báo biến môi trường cho công cụ CLI này.",
"envKeyPlaceholder": "TÊN_BIẾN",
"envValuePlaceholder": "giá trị",
"envKindSensitive": "Nhạy cảm",
"envKindValue": "Giá trị",
"envValueWarning": "Giá trị plaintext hiển thị với người dùng có quyền.",
"envValueSuspicious": "Giá trị plaintext này giống token, mật khẩu hoặc key.",
"invalidEnvKey": "\"{{key}}\" không phải tên biến môi trường hợp lệ (dùng A-Z, 0-9, gạch dưới).",
"binaryPathHint": "Để trống để tự động tìm từ PATH",
"checkBinary": "Kiểm tra",
@@ -39,6 +39,10 @@
"noEnvVarsHint": "点击\"添加变量\"为此 CLI 工具定义环境变量。",
"envKeyPlaceholder": "变量名",
"envValuePlaceholder": "值",
"envKindSensitive": "敏感",
"envKindValue": "值",
"envValueWarning": "明文值对有权限的用户可见。",
"envValueSuspicious": "此明文值看起来像令牌、密码或密钥。",
"invalidEnvKey": "\"{{key}}\" 不是有效的环境变量名(使用 A-Z、0-9、下划线)。",
"binaryPathHint": "留空自动从 PATH 检测",
"checkBinary": "检查",
@@ -9,7 +9,7 @@ import type { CLIAgentGrant } from "../hooks/use-cli-credentials";
describe("cli credential grant env helpers", () => {
it("omits env_vars when existing masked values are not revealed", () => {
const payload = buildEnvVarsPayload(
{ overrideEnabled: true, entries: [{ key: "TOKEN", value: "", masked: true }] },
{ overrideEnabled: true, entries: [{ key: "TOKEN", value: "", kind: "sensitive", masked: true }] },
true,
);
expect(payload).toBeUndefined();
@@ -20,14 +20,14 @@ describe("cli credential grant env helpers", () => {
{
overrideEnabled: true,
entries: [
{ key: " CLI_ENV ", value: "agent-value", masked: false },
{ key: "", value: "ignored", masked: false },
{ key: "MASKED", value: "", masked: true },
{ key: " CLI_ENV ", value: "agent-value", kind: "value", masked: false },
{ key: "", value: "ignored", kind: "sensitive", masked: false },
{ key: "MASKED", value: "", kind: "sensitive", masked: true },
],
},
false,
);
expect(payload).toEqual({ CLI_ENV: "agent-value" });
expect(payload).toEqual({ CLI_ENV: { kind: "value", value: "agent-value" } });
});
it("clears existing env override when override is disabled", () => {
@@ -39,13 +39,31 @@ describe("cli credential grant env helpers", () => {
const state = envStateFromGrant({
env_set: true,
env_keys: ["API_KEY", "TOKEN"],
} as CLIAgentGrant);
} as unknown as CLIAgentGrant);
expect(state).toEqual({
overrideEnabled: true,
entries: [
{ key: "API_KEY", value: "", masked: true },
{ key: "TOKEN", value: "", masked: true },
{ key: "API_KEY", value: "", kind: "sensitive", masked: true },
{ key: "TOKEN", value: "", kind: "sensitive", masked: true },
],
});
});
it("derives visible value entries from sanitized grant env metadata", () => {
const state = envStateFromGrant({
env_set: true,
env: {
PUBLIC_BASE_URL: { kind: "value", value: "https://goclaw.sh", masked: false },
TOKEN: { kind: "sensitive", value: null, masked: true },
},
} as unknown as CLIAgentGrant);
expect(state).toEqual({
overrideEnabled: true,
entries: [
{ key: "PUBLIC_BASE_URL", value: "https://goclaw.sh", kind: "value", masked: false },
{ key: "TOKEN", value: "", kind: "sensitive", masked: true },
],
});
});
@@ -4,11 +4,14 @@ import { Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { CLIPreset } from "./hooks/use-cli-credentials";
import type { CLIEnvEntryKind } from "@/types/cli-credential";
export interface ManualEnvEntry {
key: string;
value: string;
kind: CLIEnvEntryKind;
}
interface CliCredentialEnvVarsSectionProps {
@@ -20,6 +23,10 @@ interface CliCredentialEnvVarsSectionProps {
setManualEnvEntries: (updater: (prev: ManualEnvEntry[]) => ManualEnvEntry[]) => void;
}
const SUSPICIOUS_VALUE_RE = /(api[_-]?key|token|secret|password|credential|bearer\s+[a-z0-9._-]+|sk-[a-z0-9_-]{12,}|gh[pousr]_[a-z0-9_]{20,})/i;
export const isSuspiciousPlaintextEnv = (key: string, value: string) =>
SUSPICIOUS_VALUE_RE.test(`${key}=${value}`);
/** Env var inputs: preset-driven fields or free-form key/value pairs in manual mode. */
export function CliCredentialEnvVarsSection({
isManualMode,
@@ -33,14 +40,14 @@ export function CliCredentialEnvVarsSection({
const { t: tc } = useTranslation("common");
const addEntry = useCallback(() => {
setManualEnvEntries((prev) => [...prev, { key: "", value: "" }]);
setManualEnvEntries((prev) => [...prev, { key: "", value: "", kind: "sensitive" }]);
}, [setManualEnvEntries]);
const removeEntry = useCallback((index: number) => {
setManualEnvEntries((prev) => prev.filter((_, i) => i !== index));
}, [setManualEnvEntries]);
const updateEntry = useCallback((index: number, field: "key" | "value", val: string) => {
const updateEntry = useCallback((index: number, field: "key" | "value" | "kind", val: string) => {
setManualEnvEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, [field]: val } : entry)),
);
@@ -88,34 +95,57 @@ export function CliCredentialEnvVarsSection({
<p className="text-xs text-muted-foreground">{t("form.noEnvVarsHint")}</p>
)}
{manualEnvEntries.map((entry, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="grid flex-1 gap-1.5">
<Input
placeholder={t("form.envKeyPlaceholder")}
value={entry.key}
onChange={(e) => updateEntry(idx, "key", e.target.value)}
className="text-base md:text-sm font-mono"
/>
<div key={idx} className="grid gap-2 rounded-md border p-2">
<div className="flex items-start gap-2">
<div className="grid flex-1 gap-1.5">
<Input
placeholder={t("form.envKeyPlaceholder")}
value={entry.key}
onChange={(e) => updateEntry(idx, "key", e.target.value)}
className="text-base md:text-sm font-mono"
/>
</div>
<div className="grid flex-1 gap-1.5">
<Input
type={entry.kind === "sensitive" ? "password" : "text"}
autoComplete="off"
placeholder={t("form.envValuePlaceholder")}
value={entry.value}
onChange={(e) => updateEntry(idx, "value", e.target.value)}
className="text-base md:text-sm"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 h-8 w-8 shrink-0"
onClick={() => removeEntry(idx)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid flex-1 gap-1.5">
<Input
type="password"
autoComplete="off"
placeholder={t("form.envValuePlaceholder")}
value={entry.value}
onChange={(e) => updateEntry(idx, "value", e.target.value)}
className="text-base md:text-sm"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 h-8 w-8 shrink-0"
onClick={() => removeEntry(idx)}
<RadioGroup
value={entry.kind}
onValueChange={(v) => updateEntry(idx, "kind", v)}
className="flex items-center gap-4"
>
<X className="h-4 w-4" />
</Button>
<label className="flex items-center gap-1.5 text-xs">
<RadioGroupItem value="sensitive" />
{t("form.envKindSensitive")}
</label>
<label className="flex items-center gap-1.5 text-xs">
<RadioGroupItem value="value" />
{t("form.envKindValue")}
</label>
</RadioGroup>
{entry.kind === "value" && (
<p className="text-xs text-amber-700 dark:text-amber-300">
{isSuspiciousPlaintextEnv(entry.key, entry.value)
? t("form.envValueSuspicious")
: t("form.envValueWarning")}
</p>
)}
</div>
))}
</div>
@@ -17,6 +17,7 @@ import { CliCredentialEnvVarsSection } from "./cli-credential-env-vars-section";
import { CliCredentialBinaryFields } from "./cli-credential-binary-fields";
import { CliCredentialScopeFields } from "./cli-credential-scope-fields";
import { cliCredentialSchema, type CliCredentialFormData } from "@/schemas/credential.schema";
import type { CLIEnvEntryResponse, CLIEnvPayload } from "@/types/cli-credential";
interface Props {
open: boolean;
@@ -29,6 +30,20 @@ interface Props {
const NONE_PRESET = "__none__";
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
function manualEntriesFromEnv(
env: Record<string, CLIEnvEntryResponse> | undefined,
fallbackKeys: string[],
): ManualEnvEntry[] {
if (env && Object.keys(env).length > 0) {
return Object.entries(env).map(([key, entry]) => ({
key,
value: entry.value ?? "",
kind: entry.kind ?? "sensitive",
}));
}
return fallbackKeys.map((key) => ({ key, value: "", kind: "sensitive" }));
}
export function CliCredentialFormDialog({ open, onOpenChange, credential, presets, onSubmit }: Props) {
const { t } = useTranslation("cli-credentials");
const { t: tc } = useTranslation("common");
@@ -91,13 +106,13 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
return;
}
const applyEnvKeys = (keys: string[]) => {
const applyEnvState = (env: Record<string, CLIEnvEntryResponse> | undefined, keys: string[]) => {
setInitialEnvKeys(keys);
setManualEnvEntries(keys.length > 0 ? keys.map((k) => ({ key: k, value: "" })) : []);
setManualEnvEntries(manualEntriesFromEnv(env, keys));
};
if (credential.env_keys !== undefined) {
applyEnvKeys(credential.env_keys ?? []);
applyEnvState(credential.env, credential.env_keys ?? []);
return;
}
@@ -106,9 +121,9 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
try {
const full = await http.get<SecureCLIBinary>(`/v1/cli-credentials/${credential.id}`);
if (cancelled) return;
applyEnvKeys(full.env_keys ?? []);
applyEnvState(full.env, full.env_keys ?? []);
} catch {
if (!cancelled) applyEnvKeys([]);
if (!cancelled) applyEnvState(undefined, []);
}
})();
return () => { cancelled = true; };
@@ -156,16 +171,22 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
const splitCommaList = (v: string): string[] =>
v.split(",").map((s) => s.trim()).filter(Boolean);
const buildEnvPayload = (): Record<string, string> | null => {
if (!isManualMode) return envValues;
const env: Record<string, string> = {};
const buildEnvPayload = (): CLIEnvPayload | null => {
if (!isManualMode) {
const presetEnv: CLIEnvPayload = {};
for (const [key, value] of Object.entries(envValues)) {
presetEnv[key] = { kind: "sensitive", value };
}
return presetEnv;
}
const env: CLIEnvPayload = {};
for (const entry of manualEnvEntries) {
const k = entry.key.trim();
if (k && !ENV_KEY_PATTERN.test(k)) {
setError(t("form.invalidEnvKey", { key: k }));
return null;
}
if (k) env[k] = entry.value;
if (k) env[k] = { kind: entry.kind, value: entry.value };
}
return env;
};
@@ -0,0 +1,73 @@
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { isSuspiciousPlaintextEnv } from "./cli-credential-env-vars-section";
import type { GrantEnvEntry } from "./cli-credential-grant-env-section";
interface Props {
entry: GrantEnvEntry;
hasError: boolean;
onRemove: () => void;
onUpdate: (field: "key" | "value" | "kind", value: string) => void;
}
export function CliCredentialGrantEnvRow({ entry, hasError, onRemove, onUpdate }: Props) {
const { t } = useTranslation("cli-credentials");
return (
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-[11rem] flex-1">
<Input
placeholder={t("grants.envVars.keyPlaceholder")}
value={entry.key}
onChange={(e) => onUpdate("key", e.target.value)}
className={`text-base md:text-sm font-mono${hasError ? " border-destructive" : ""}`}
/>
{hasError && (
<p className="mt-0.5 text-xs text-destructive">
{t("grants.envVars.deniedKey", { key: entry.key })}
</p>
)}
</div>
<div className="min-w-[11rem] flex-1">
{entry.masked ? (
<Input
disabled
value={t("grants.envVars.revealHidden")}
className="text-base md:text-sm text-muted-foreground italic"
/>
) : (
<Input
type="password"
autoComplete="off"
placeholder={t("grants.envVars.valuePlaceholder")}
value={entry.value}
onChange={(e) => onUpdate("value", e.target.value)}
className="text-base md:text-sm"
/>
)}
</div>
{!entry.masked && (
<select
value={entry.kind}
onChange={(e) => onUpdate("kind", e.target.value)}
className="h-9 rounded-md border bg-background px-2 text-base md:text-sm"
>
<option value="sensitive">{t("form.envKindSensitive")}</option>
<option value="value">{t("form.envKindValue")}</option>
</select>
)}
<Button type="button" variant="ghost" size="icon" className="mt-0.5 h-8 w-8 shrink-0" onClick={onRemove}>
<X className="h-4 w-4" />
</Button>
{!entry.masked && entry.kind === "value" && (
<p className="basis-full text-xs text-amber-700 dark:text-amber-300">
{isSuspiciousPlaintextEnv(entry.key, entry.value)
? t("form.envValueSuspicious")
: t("form.envValueWarning")}
</p>
)}
</div>
);
}
@@ -6,13 +6,14 @@
*/
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Plus, X, Eye } from "lucide-react";
import { Plus, Eye } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { toast } from "@/stores/use-toast-store";
import { useHttp } from "@/hooks/use-ws";
import type { CLIEnvEntryKind } from "@/types/cli-credential";
import { CliCredentialGrantEnvRow } from "./cli-credential-grant-env-row";
// Keep in sync with internal/crypto/env_denylist.go.
// Backend is authoritative; this list drives inline UX warnings only.
@@ -23,7 +24,7 @@ const ENV_DENYLIST_EXACT = new Set([
"PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP",
"GIT_SSH_COMMAND", "GIT_SSH", "GIT_EXEC_PATH", "GIT_CONFIG_SYSTEM",
"SSH_AUTH_SOCK",
// Finding #6 additions — keep in sync with internal/crypto/env_denylist.go
// Shell startup and proxy/certificate variables can alter command behavior.
"BASH_ENV", "ENV", "PROMPT_COMMAND",
"PERL5LIB", "RUBYOPT",
"HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY",
@@ -36,6 +37,7 @@ const ENV_DENYLIST_PREFIXES = ["DYLD_", "GOCLAW_", "LD_", "NPM_CONFIG_"];
export interface GrantEnvEntry {
key: string;
value: string;
kind: CLIEnvEntryKind;
masked: boolean; // true = not yet revealed from server
}
@@ -63,10 +65,10 @@ export function CliCredentialGrantEnvSection({
const [revealing, setRevealing] = useState(false);
const [revealed, setRevealed] = useState(false);
const { overrideEnabled, entries } = state;
// Finding #10: track blur timeout so we can cancel it on reveal/unmount.
// Track reveal timeout so plaintext can be cleared on reveal refresh/unmount.
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Finding #10: clear revealed plaintext from entries on component unmount.
// Clear revealed plaintext from entries on component unmount.
// This is defense-in-depth — plaintext should not persist in React state beyond use.
useEffect(() => {
return () => {
@@ -77,7 +79,6 @@ export function CliCredentialGrantEnvSection({
entries: state.entries.map((e) => ({ ...e, value: "", masked: e.masked })),
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setEntries = useCallback(
@@ -89,10 +90,10 @@ export function CliCredentialGrantEnvSection({
const handleToggle = useCallback((checked: boolean) => {
if (checked) {
if (initialEnvSet && !revealed && entries.every((e) => e.masked)) {
const masked: GrantEnvEntry[] = initialEnvKeys.map((k) => ({ key: k, value: "", masked: true }));
onChange({ overrideEnabled: true, entries: masked.length > 0 ? masked : [{ key: "", value: "", masked: false }] });
const masked: GrantEnvEntry[] = initialEnvKeys.map((k) => ({ key: k, value: "", kind: "sensitive", masked: true }));
onChange({ overrideEnabled: true, entries: masked.length > 0 ? masked : [{ key: "", value: "", kind: "sensitive", masked: false }] });
} else if (entries.length === 0) {
onChange({ overrideEnabled: true, entries: [{ key: "", value: "", masked: false }] });
onChange({ overrideEnabled: true, entries: [{ key: "", value: "", kind: "sensitive", masked: false }] });
} else {
onChange({ overrideEnabled: true, entries });
}
@@ -105,16 +106,19 @@ export function CliCredentialGrantEnvSection({
if (!grantId) return;
setRevealing(true);
try {
// POST — not GET (C1 red-team). Direct call, not cached by TanStack Query.
// POST keeps reveal out of URL/history and avoids query caching.
const res = await http.post<{ env_vars: Record<string, string> }>(
`/v1/cli-credentials/${binaryId}/agent-grants/${grantId}/env:reveal`,
);
const filled: GrantEnvEntry[] = Object.entries(res.env_vars).map(([k, v]) => ({
key: k, value: v, masked: false,
key: k,
value: v,
kind: entries.find((entry) => entry.key === k)?.kind ?? "sensitive",
masked: false,
}));
onChange({ overrideEnabled: true, entries: filled.length > 0 ? filled : entries });
setRevealed(true);
// Finding #10: wipe plaintext after 30s of inactivity (defense-in-depth).
// Wipe plaintext after 30s of inactivity.
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);
blurTimeoutRef.current = setTimeout(() => {
onChange({
@@ -133,9 +137,9 @@ export function CliCredentialGrantEnvSection({
}
}, [grantId, binaryId, http, onChange, entries, t]);
const addEntry = useCallback(() => setEntries((p) => [...p, { key: "", value: "", masked: false }]), [setEntries]);
const addEntry = useCallback(() => setEntries((p) => [...p, { key: "", value: "", kind: "sensitive", masked: false }]), [setEntries]);
const removeEntry = useCallback((i: number) => setEntries((p) => p.filter((_, j) => j !== i)), [setEntries]);
const updateEntry = useCallback((i: number, f: "key" | "value", v: string) =>
const updateEntry = useCallback((i: number, f: "key" | "value" | "kind", v: string) =>
setEntries((p) => p.map((e, j) => j === i ? { ...e, [f]: v, masked: false } : e)), [setEntries]);
const isDenied = (k: string) => {
@@ -174,32 +178,13 @@ export function CliCredentialGrantEnvSection({
{entries.map((entry, idx) => {
const hasError = isDenied(entry.key) || isRejected(entry.key);
return (
<div key={idx} className="flex items-start gap-2">
<div className="flex-1">
<Input placeholder={t("grants.envVars.keyPlaceholder")} value={entry.key}
onChange={(e) => updateEntry(idx, "key", e.target.value)}
className={`text-base md:text-sm font-mono${hasError ? " border-destructive" : ""}`} />
{hasError && (
<p className="text-xs text-destructive mt-0.5">
{t("grants.envVars.deniedKey", { key: entry.key })}
</p>
)}
</div>
<div className="flex-1">
{entry.masked ? (
<Input disabled value={t("grants.envVars.revealHidden")}
className="text-base md:text-sm text-muted-foreground italic" />
) : (
<Input type="password" autoComplete="off" placeholder={t("grants.envVars.valuePlaceholder")}
value={entry.value} onChange={(e) => updateEntry(idx, "value", e.target.value)}
className="text-base md:text-sm" />
)}
</div>
<Button type="button" variant="ghost" size="icon" className="mt-0.5 h-8 w-8 shrink-0"
onClick={() => removeEntry(idx)}>
<X className="h-4 w-4" />
</Button>
</div>
<CliCredentialGrantEnvRow
key={idx}
entry={entry}
hasError={hasError}
onRemove={() => removeEntry(idx)}
onUpdate={(field, value) => updateEntry(idx, field, value)}
/>
);
})}
{entries.length === 0 && (
@@ -3,6 +3,7 @@
*/
import type { CLIAgentGrant } from "./hooks/use-cli-credentials";
import type { GrantEnvState, GrantEnvEntry } from "./cli-credential-grant-env-section";
import type { CLIEnvPayload } from "@/types/cli-credential";
export const EMPTY_ENV_STATE: GrantEnvState = { overrideEnabled: false, entries: [] };
@@ -15,14 +16,14 @@ export const EMPTY_ENV_STATE: GrantEnvState = { overrideEnabled: false, entries:
export function buildEnvVarsPayload(
envState: GrantEnvState,
originalEnvSet: boolean,
): Record<string, string> | null | undefined {
): CLIEnvPayload | null | undefined {
const { overrideEnabled, entries } = envState;
if (overrideEnabled) {
const allMasked = entries.length > 0 && entries.every((e: GrantEnvEntry) => e.masked);
if (allMasked) return undefined; // not revealed; don't overwrite
const result: Record<string, string> = {};
const result: CLIEnvPayload = {};
for (const e of entries) {
if (!e.masked && e.key.trim()) result[e.key.trim()] = e.value;
if (!e.masked && e.key.trim()) result[e.key.trim()] = { kind: e.kind, value: e.value };
}
return result;
}
@@ -31,10 +32,23 @@ export function buildEnvVarsPayload(
/** Derive initial GrantEnvState from an existing grant. */
export function envStateFromGrant(grant: CLIAgentGrant): GrantEnvState {
if (grant.env && Object.keys(grant.env).length > 0) {
return {
overrideEnabled: true,
entries: Object.entries(grant.env)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, entry]) => ({
key,
value: entry.kind === "value" && entry.value !== null ? entry.value : "",
kind: entry.kind,
masked: entry.masked,
})),
};
}
if (grant.env_set && grant.env_keys && grant.env_keys.length > 0) {
return {
overrideEnabled: true,
entries: grant.env_keys.map((k) => ({ key: k, value: "", masked: true })),
entries: grant.env_keys.map((k) => ({ key: k, value: "", kind: "sensitive", masked: true })),
};
}
return EMPTY_ENV_STATE;
@@ -13,11 +13,12 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { UserPickerCombobox } from "@/components/shared/user-picker-combobox";
import { KeyValueEditor } from "@/components/shared/key-value-editor";
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 type { SecureCLIBinary } from "./hooks/use-cli-credentials";
import type { CLIEnvEntryResponse, CLIEnvPayload } from "@/types/cli-credential";
interface UserCredEntry {
id: string;
@@ -36,11 +37,26 @@ interface CLIUserCredentialsDialogProps {
binary: SecureCLIBinary;
}
const SENSITIVE_ENV_RE = /^.*(key|secret|token|password|credential).*$/i;
const isSensitiveEnv = (key: string) => SENSITIVE_ENV_RE.test(key.trim());
type ViewState = "list" | "form";
function entriesFromEnv(env: Record<string, CLIEnvEntryResponse> | null | undefined): ManualEnvEntry[] {
if (!env || Object.keys(env).length === 0) return [];
return Object.entries(env).map(([key, entry]) => ({
key,
value: entry.value ?? "",
kind: entry.kind ?? "sensitive",
}));
}
function envPayloadFromEntries(entries: ManualEnvEntry[]): CLIEnvPayload {
const env: CLIEnvPayload = {};
for (const entry of entries) {
const key = entry.key.trim();
if (key) env[key] = { kind: entry.kind, value: entry.value };
}
return env;
}
export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUserCredentialsDialogProps) {
const { t } = useTranslation("cli-credentials");
const http = useHttp();
@@ -54,7 +70,7 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
const [userId, setUserId] = useState("");
// Separate search text from selected value (onChange fires on every keystroke)
const [userSearchText, setUserSearchText] = useState("");
const [env, setEnv] = useState<Record<string, string>>({});
const [envEntries, setEnvEntries] = useState<ManualEnvEntry[]>([]);
const [saving, setSaving] = useState(false);
const [deleting, setDeletingId] = useState<string | null>(null);
@@ -80,7 +96,7 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
setEditEntry(null);
setUserId("");
setUserSearchText("");
setEnv({});
setEnvEntries([]);
loadList();
}, [open, loadList]);
@@ -88,7 +104,7 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
setEditEntry(null);
setUserId("");
setUserSearchText("");
setEnv({});
setEnvEntries([]);
setView("form");
};
@@ -96,14 +112,14 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
setEditEntry(entry);
setUserId(entry.user_id);
setUserSearchText(entry.user_id);
setEnv({});
setEnvEntries([]);
setView("form");
// Load existing env for edit
try {
const res = await http.get<{ user_id: string; env: Record<string, string> | null }>(
const res = await http.get<{ user_id: string; env: Record<string, CLIEnvEntryResponse> | null }>(
`/v1/cli-credentials/${binary.id}/user-credentials/${entry.user_id}`,
);
setEnv(res.env ?? {});
setEnvEntries(entriesFromEnv(res.env));
} catch {
// leave env empty — user can re-enter
}
@@ -112,6 +128,7 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
const handleSave = async () => {
const uid = userId.trim();
if (!uid) return;
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) {
toast.error(i18next.t("cli-credentials:userCredentials.envRequired"));
@@ -251,13 +268,13 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
<div className="flex flex-col gap-1.5">
<Label>{t("userCredentials.env")}</Label>
<KeyValueEditor
value={env}
onChange={setEnv}
keyPlaceholder="ENV_KEY"
valuePlaceholder="value"
addLabel={t("userCredentials.addEnv")}
maskValue={isSensitiveEnv}
<CliCredentialEnvVarsSection
isManualMode
activePreset={null}
envValues={{}}
setEnvValues={() => undefined}
manualEnvEntries={envEntries}
setManualEnvEntries={setEnvEntries}
/>
</div>
</div>
+22 -3
View File
@@ -1,3 +1,18 @@
export type CLIEnvEntryKind = "sensitive" | "value";
export interface CLIEnvEntryInput {
kind: CLIEnvEntryKind;
value: string;
}
export interface CLIEnvEntryResponse {
kind: CLIEnvEntryKind;
value: string | null;
masked: boolean;
}
export type CLIEnvPayload = Record<string, string | CLIEnvEntryInput>;
export interface SecureCLIBinary {
id: string;
binary_name: string;
@@ -14,6 +29,8 @@ export interface SecureCLIBinary {
updated_at: string;
/** Env variable names only (no values); from API for edit form */
env_keys?: string[];
/** Sanitized env metadata; sensitive values are masked, value entries include value. */
env?: Record<string, CLIEnvEntryResponse>;
/**
* Agent grants summary for row chips (Phase 4 API field).
* Absent on older API versions — capability-probe: skip rendering if undefined.
@@ -49,7 +66,7 @@ export interface CLICredentialInput {
tips?: string;
is_global?: boolean;
enabled?: boolean;
env?: Record<string, string>;
env?: CLIEnvPayload;
}
/** Per-agent grant with optional setting overrides */
@@ -66,6 +83,8 @@ export interface CLIAgentGrant {
env_set?: boolean;
/** Env variable names only (no values); populated when env_set=true */
env_keys?: string[];
/** Sanitized env metadata; sensitive values are masked, value entries include value. */
env?: Record<string, CLIEnvEntryResponse>;
created_at: string;
updated_at: string;
}
@@ -78,7 +97,7 @@ export interface CLIAgentGrantInput {
tips?: string | null;
enabled?: boolean;
/**
* env_vars semantics — 3-state, all three distinct behaviors (Finding #15):
* env_vars semantics — 3-state, all three distinct behaviors:
*
* - **absent / undefined** → keep existing env override (omit from request payload)
* - **null** → clear override; grant falls back to binary-level defaults
@@ -88,7 +107,7 @@ export interface CLIAgentGrantInput {
* Backend: internal/http/secure_cli_agent_grants.go handleUpdate (3-state env_vars branch).
* Keys must match ^[A-Z_][A-Z0-9_]*$ and must not be on the denylist.
*/
env_vars?: Record<string, string> | null;
env_vars?: CLIEnvPayload | null;
}
/** Summary of a single grant shown in the table row chips (Phase 4 API field). */