package http import ( "encoding/json" "fmt" "log/slog" "net/http" "os/exec" "regexp" "strings" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // safeBinaryNameRe allows only simple binary names: alphanumeric, hyphens, underscores, dots. // No path separators or shell metacharacters — prevents filesystem probing via LookPath. var safeBinaryNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) // SecureCLIHandler handles secure CLI binary credential CRUD endpoints. type SecureCLIHandler struct { store store.SecureCLIStore agentCreds store.SecureCLIAgentCredentialStore tenants store.TenantStore msgBus *bus.MessageBus } // NewSecureCLIHandler creates a handler for secure CLI credential management. func NewSecureCLIHandler(s store.SecureCLIStore, msgBus *bus.MessageBus, tenants ...store.TenantStore) *SecureCLIHandler { h := &SecureCLIHandler{store: s, msgBus: msgBus} if len(tenants) > 0 { h.tenants = tenants[0] } if agentCreds, ok := s.(store.SecureCLIAgentCredentialStore); ok { h.agentCreds = agentCreds } return h } // RegisterRoutes registers all secure CLI routes on the given mux. func (h *SecureCLIHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/cli-credentials", h.auth(h.handleList)) mux.HandleFunc("POST /v1/cli-credentials", h.auth(h.handleCreate)) mux.HandleFunc("GET /v1/cli-credentials/presets", h.auth(h.handlePresets)) mux.HandleFunc("POST /v1/cli-credentials/check-binary", h.auth(h.handleCheckBinary)) mux.HandleFunc("GET /v1/cli-credentials/{id}", h.auth(h.handleGet)) mux.HandleFunc("PUT /v1/cli-credentials/{id}", h.auth(h.handleUpdate)) mux.HandleFunc("DELETE /v1/cli-credentials/{id}", h.auth(h.handleDelete)) mux.HandleFunc("POST /v1/cli-credentials/{id}/test", h.auth(h.handleDryRun)) // Per-user credential management mux.HandleFunc("GET /v1/cli-credentials/{id}/user-credentials", h.auth(h.handleListUserCredentials)) mux.HandleFunc("GET /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleGetUserCredentials)) mux.HandleFunc("PUT /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleSetUserCredentials)) mux.HandleFunc("DELETE /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleDeleteUserCredentials)) // Per-agent credential management. Credentials do not grant binary access; // non-global binaries still require agent-grants. mux.HandleFunc("GET /v1/cli-credentials/{id}/agent-credentials", h.auth(h.handleListAgentCredentials)) mux.HandleFunc("GET /v1/cli-credentials/{id}/agent-credentials/{agentId}", h.auth(h.handleGetAgentCredentials)) mux.HandleFunc("PUT /v1/cli-credentials/{id}/agent-credentials/{agentId}", h.auth(h.handleSetAgentCredentials)) mux.HandleFunc("DELETE /v1/cli-credentials/{id}/agent-credentials/{agentId}", h.auth(h.handleDeleteAgentCredentials)) } func (h *SecureCLIHandler) auth(next http.HandlerFunc) http.HandlerFunc { return requireAuth(permissions.RoleAdmin, next) } func (h *SecureCLIHandler) emitCacheInvalidate(key string) { if h.msgBus == nil { return } h.msgBus.Broadcast(bus.Event{ Name: protocol.EventCacheInvalidate, Payload: bus.CacheInvalidatePayload{Kind: "secure_cli", Key: key}, }) } // envKeysFromDecryptedJSON returns sorted env variable names from plaintext env JSON (decrypted blob). func envKeysFromDecryptedJSON(env []byte) []string { return store.SecureCLIEnvKeys(env) } 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) { locale := store.LocaleFromContext(r.Context()) result, err := h.store.List(r.Context()) if err != nil { slog.Error("secure_cli.list", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "CLI credentials")}) return } // Sensitive env values stay masked; value-kind entries are returned for editing. for i := range result { 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 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) { locale := store.LocaleFromContext(r.Context()) var req secureCLICreateRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) return } var preset *tools.CLIPreset // Apply preset defaults if specified if req.Preset != "" { preset = tools.GetPreset(req.Preset) if preset == nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown preset: " + req.Preset}) return } if req.BinaryName == "" { req.BinaryName = preset.BinaryName } if req.Description == "" { req.Description = preset.Description } if len(req.DenyArgs) == 0 { req.DenyArgs, _ = json.Marshal(preset.DenyArgs) } if len(req.DenyVerbose) == 0 { req.DenyVerbose, _ = json.Marshal(preset.DenyVerbose) } if req.TimeoutSeconds <= 0 { req.TimeoutSeconds = preset.Timeout } if req.Tips == "" { req.Tips = preset.Tips } } if req.BinaryName == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "binary_name")}) return } adapterManagedPreset := preset != nil && strings.TrimSpace(preset.AdapterName) != "" && len(preset.EnvVars) == 0 if len(req.Env) == 0 && !adapterManagedPreset { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "env")}) return } envJSON := []byte("{}") if len(req.Env) > 0 { envEntries, err := store.ParseSecureCLIEnv(req.Env) if err != nil { 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 } } var adapterName *string if preset != nil && strings.TrimSpace(preset.AdapterName) != "" { name := strings.TrimSpace(preset.AdapterName) adapterName = &name } b := &store.SecureCLIBinary{ BinaryName: req.BinaryName, BinaryPath: req.BinaryPath, Description: req.Description, EncryptedEnv: envJSON, DenyArgs: req.DenyArgs, DenyVerbose: req.DenyVerbose, TimeoutSeconds: req.TimeoutSeconds, Tips: req.Tips, IsGlobal: req.IsGlobal == nil || *req.IsGlobal, // default true Enabled: req.Enabled, CreatedBy: store.UserIDFromContext(r.Context()), AdapterName: adapterName, } if b.TimeoutSeconds <= 0 { b.TimeoutSeconds = 30 } if err := h.store.Create(r.Context(), b); err != nil { slog.Error("secure_cli.create", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } tools.ResetCredentialScrubValues() // clear stale scrub values populateBinaryEnvResponse(b) emitAudit(h.msgBus, r, "secure_cli.created", "secure_cli", b.ID.String()) h.emitCacheInvalidate(b.ID.String()) writeJSON(w, http.StatusCreated, b) } func (h *SecureCLIHandler) handleGet(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")}) return } b, 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 } populateBinaryEnvResponse(b) writeJSON(w, http.StatusOK, b) } func (h *SecureCLIHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")}) return } var updates map[string]any if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) return } // Allowlist of updatable fields to prevent column injection allowed := map[string]bool{ "binary_name": true, "binary_path": true, "description": true, "env": true, "deny_args": true, "deny_verbose": true, "timeout_seconds": true, "tips": true, "is_global": true, "enabled": true, } for k := range updates { if !allowed[k] { delete(updates, k) } } // If env is updated, merge with stored env so empty values mean "keep existing secret". if envVal, ok := updates["env"]; ok { 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 { slog.Error("secure_cli.update", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } tools.ResetCredentialScrubValues() // clear stale scrub values emitAudit(h.msgBus, r, "secure_cli.updated", "secure_cli", id.String()) h.emitCacheInvalidate(id.String()) writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } func (h *SecureCLIHandler) handleDelete(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")}) return } if err := h.store.Delete(r.Context(), id); err != nil { slog.Error("secure_cli.delete", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } tools.ResetCredentialScrubValues() // clear stale scrub values emitAudit(h.msgBus, r, "secure_cli.deleted", "secure_cli", id.String()) h.emitCacheInvalidate(id.String()) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } type cliPresetResponse struct { BinaryName string `json:"binary_name"` Description string `json:"description"` EnvVars []tools.EnvVarDef `json:"env_vars"` DenyArgs []string `json:"deny_args"` DenyVerbose []string `json:"deny_verbose"` Timeout int `json:"timeout"` Tips string `json:"tips"` AdapterName string `json:"adapter_name,omitempty"` } func normalizedCLIPresets() map[string]cliPresetResponse { out := make(map[string]cliPresetResponse, len(tools.CLIPresets)) for key, preset := range tools.CLIPresets { envVars := preset.EnvVars if envVars == nil { envVars = []tools.EnvVarDef{} } denyArgs := preset.DenyArgs if denyArgs == nil { denyArgs = []string{} } denyVerbose := preset.DenyVerbose if denyVerbose == nil { denyVerbose = []string{} } out[key] = cliPresetResponse{ BinaryName: preset.BinaryName, Description: preset.Description, EnvVars: envVars, DenyArgs: denyArgs, DenyVerbose: denyVerbose, Timeout: preset.Timeout, Tips: preset.Tips, AdapterName: preset.AdapterName, } } return out } func (h *SecureCLIHandler) handlePresets(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"presets": normalizedCLIPresets()}) } // handleCheckBinary resolves a binary name to its absolute path via exec.LookPath. func (h *SecureCLIHandler) handleCheckBinary(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) var req struct { BinaryName string `json:"binary_name"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) return } if req.BinaryName == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "binary_name")}) return } // Security: only allow simple binary names (no path separators, no shell metacharacters). // This prevents probing arbitrary filesystem paths via LookPath. if !safeBinaryNameRe.MatchString(req.BinaryName) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid binary name"}) return } absPath, err := exec.LookPath(req.BinaryName) if err != nil { if runtimePath, ok := skills.FindRuntimeExecutable(req.BinaryName); ok { writeJSON(w, http.StatusOK, map[string]any{"found": true, "path": runtimePath}) return } writeJSON(w, http.StatusOK, map[string]any{"found": false, "error": fmt.Sprintf("binary %q not found in PATH", req.BinaryName)}) return } writeJSON(w, http.StatusOK, map[string]any{"found": true, "path": absPath}) } // dryRunRequest tests commands against deny patterns. type dryRunRequest struct { TestCommands []string `json:"test_commands"` } type dryRunResult struct { Command string `json:"command"` Allowed bool `json:"allowed"` MatchedDeny *string `json:"matched_deny"` } func (h *SecureCLIHandler) handleDryRun(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")}) return } b, 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 } var req dryRunRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) return } // Parse deny patterns from config var denyArgs, denyVerbose []string _ = json.Unmarshal(b.DenyArgs, &denyArgs) _ = json.Unmarshal(b.DenyVerbose, &denyVerbose) allPatterns := append(denyArgs, denyVerbose...) results := make([]dryRunResult, 0, len(req.TestCommands)) for _, cmd := range req.TestCommands { result := dryRunResult{Command: cmd, Allowed: true} // Strip binary name prefix to get just the args portion argsStr := cmd if strings.HasPrefix(cmd, b.BinaryName+" ") { argsStr = cmd[len(b.BinaryName)+1:] } for _, p := range allPatterns { re, err := regexp.Compile(p) if err != nil { continue } if re.MatchString(argsStr) { result.Allowed = false result.MatchedDeny = &p break } } results = append(results, result) } writeJSON(w, http.StatusOK, map[string]any{"results": results}) }