diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index e47def21..c270af1d 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -112,7 +112,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir } if stores != nil && stores.SecureCLI != nil { - secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, msgBus) + secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, msgBus, stores.Tenants) } if stores != nil && stores.SecureCLIGrants != nil { secureCLIGrantH = httpapi.NewSecureCLIGrantHandler(stores.SecureCLIGrants, stores.Tenants, msgBus) diff --git a/docs/06-store-data-model.md b/docs/06-store-data-model.md index e71bc3a1..e2fcbae7 100644 --- a/docs/06-store-data-model.md +++ b/docs/06-store-data-model.md @@ -480,7 +480,14 @@ Pre-computed usage snapshots (hourly aggregations) for analytics dashboards. Tra ### SecureCLIStore -CLI binary credential configuration with encrypted environment variable injection. Credentials are auto-injected into child processes without exposing them to command output. +CLI binary credential configuration with encrypted environment variable +injection. Credentials are auto-injected into child processes without exposing +them to command output. + +Credential rows can live at binary, agent, channel/context, or user scope. +Runtime resolution prefers user overrides, then context credentials, then agent +credentials, then binary defaults. The `secure_cli_agent_credentials` table +stores one encrypted PAT/SSH/env payload per `(binary_id, agent_id, tenant_id)`. | Method | Purpose | |--------|---------| @@ -492,6 +499,9 @@ CLI binary credential configuration with encrypted environment variable injectio | `ListByAgent(agentID)` | Return configs for a specific agent | | `LookupByBinary(binaryName, agentID)` | Find best-matching config (agent-specific > global) | | `ListEnabled()` | Return enabled configs for TOOLS.md generation | +| `ListAgentCredentials(binaryID)` | Return masked agent credential metadata | +| `SetAgentCredentialsTyped(binaryID, agentID, env, type, hostScope)` | Store agent-scoped PAT/SSH/env payload | +| `DeleteAgentCredentials(binaryID, agentID)` | Remove an agent-scoped credential | ### APIKeyStore diff --git a/docs/09-security.md b/docs/09-security.md index eb3c22be..6931c196 100644 --- a/docs/09-security.md +++ b/docs/09-security.md @@ -231,7 +231,7 @@ 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` | +| Credentialed CLI env vars | `secure_cli_binaries`, `secure_cli_agent_grants`, `secure_cli_user_credentials`, `secure_cli_agent_credentials` | `encrypted_env` | **Format**: `"aes-gcm:" + base64(12-byte nonce + ciphertext + GCM tag)` @@ -534,6 +534,11 @@ Implementer guide: [credential-adapter-playbook.md](./credential-adapter-playboo Both paths coexist. A typo in `adapter_name` falls back to passthrough, which restores the legacy denylist-only behavior — no silent bypass. +Runtime credential precedence is explicit: user override, then channel/context +credential, then agent credential, then binary-level env defaults. Agent +credentials are the default git trust boundary; granting access to an agent +also grants the ability to make that agent use its stored git credential. + ### Audit log: `security.system_env_injection` Every adapter injection emits **exactly one** structured slog line. Field @@ -547,6 +552,7 @@ the test and operator-facing log-search recipes. | `adapter` | string | e.g. `git`, `psql`, `passthrough` | | `binary` | string | binary name (`git`, `kubectl`, …) | | `user_id` | string | tenant user UUID (empty for global-only contexts) | +| `credential_source` | string | `user`, `context`, `agent`, or empty when no scoped credential row was selected | | `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"` | diff --git a/docs/18-http-api.md b/docs/18-http-api.md index ae7fc6cd..93dbf079 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -1173,8 +1173,45 @@ CLI authentication credentials for secure command execution. Requires **admin ro | `DELETE` | `/v1/cli-credentials/{id}` | Delete credential | | `POST` | `/v1/cli-credentials/{id}/test` | Test credential connection (dry-run) | +### Agent Credentials + +Agent credentials store PAT/SSH/env material for one CLI credential and one +agent. They are the default git setup path; agent access controls who can cause +the credential to be used. Responses return metadata only and never include raw +typed token/key blobs. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/cli-credentials/{id}/agent-credentials` | List agent credentials for CLI cred | +| `GET` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Get agent credential metadata | +| `PUT` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Set agent credential | +| `DELETE` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Delete agent credential | + +Typed git request body: + +```json +{ + "credential_type": "pat", + "host_scope": "github.com", + "blob": { "token": "ghp_..." } +} +``` + +Env request body: + +```json +{ + "env": { + "GH_TOKEN": { "kind": "sensitive", "value": "..." } + } +} +``` + ### Per-User Credentials +Advanced personal overrides. These remain for backward compatibility and have +higher runtime precedence than channel/context and agent credentials. + | Method | Path | Description | |--------|------|-------------| | `GET` | `/v1/cli-credentials/{id}/user-credentials` | List user credentials for CLI cred | diff --git a/docs/20-api-keys-auth.md b/docs/20-api-keys-auth.md index 483c829f..ae5e6187 100644 --- a/docs/20-api-keys-auth.md +++ b/docs/20-api-keys-auth.md @@ -335,6 +335,10 @@ CREATE TABLE secure_cli_binaries ( | `DELETE` | `/v1/cli-credentials/{id}` | Delete a SecureCLI config | | `POST` | `/v1/cli-credentials/{id}/test` | Dry-run test (requires admin) | | `GET` | `/v1/cli-credentials/presets` | List preset templates for common CLIs | +| `GET` | `/v1/cli-credentials/{id}/agent-credentials` | List agent-scoped credentials | +| `GET` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Get agent-scoped credential metadata | +| `PUT` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Create or replace agent-scoped credential | +| `DELETE` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Delete agent-scoped credential | ### Google Workspace CLI preset diff --git a/docs/git-credential-adapter.md b/docs/git-credential-adapter.md index 552008fc..25715c1f 100644 --- a/docs/git-credential-adapter.md +++ b/docs/git-credential-adapter.md @@ -24,9 +24,31 @@ The typed `git` adapter accepts either a **Personal Access Token (PAT)** or an | **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) +## Adding an agent credential (UI) -1. Open **Settings → CLI Credentials → User Credentials → Add**. +Agent credentials are the default path for git auth. They avoid channel-user +ID ambiguity: the selected agent owns the credential, and anyone allowed to use +that agent can cause it to run git with the stored credential. + +1. Open **Packages → CLI Credentials**. +2. Pick the `git` row and open **Agent Credentials**. +3. Select the agent. +4. Choose **Credential Type**: `Personal Access Token` or `SSH Private Key`. +5. 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. +6. Paste the token (PAT) or the unencrypted PEM body (SSH). +7. Save. + +## Advanced user overrides + +Per-user credentials remain available for personal overrides and backward +compatibility. Use them only when a stable tenant user ID is the intended +credential boundary. + +1. Open **Packages → CLI Credentials → Advanced User Overrides → 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 @@ -41,6 +63,13 @@ 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. +Effective credential precedence is: + +1. User override. +2. Channel/context credential. +3. Agent credential. +4. Binary-level env defaults. + ## What gets auto-injected The adapter runs ONLY for these subcommands: @@ -140,7 +169,7 @@ Every successful credential injection emits exactly one structured log line: ``` level=WARN msg=security.system_env_injection - adapter=git binary=git user_id= + adapter=git binary=git user_id= credential_source=agent env_keys=[GIT_CONFIG_COUNT,GIT_CONFIG_KEY_0,GIT_CONFIG_VALUE_0] argv_prefix_len=0 host_scope_hash=3aeb0024 @@ -159,12 +188,12 @@ 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. +or `= 'env'` continue to work via the passthrough adapter. Existing user +overrides remain higher precedence than agent credentials. 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. +To move to the agent-scoped model, create a matching Agent Credential for the +agent and remove the user override when the override is no longer needed. ## Operator notes @@ -186,7 +215,8 @@ atomically. ## Known limitations (v1) -- One credential per (user, binary, host_scope) row. +- One credential per (agent, binary) row, plus legacy one credential per + (user, binary) override. - No multi-host wildcard (`*.github.com`). - No passphrase-protected SSH keys. - No persistent `known_hosts` per credential (TOFU only). diff --git a/docs/project-changelog.md b/docs/project-changelog.md index d8a84842..3eef23b6 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -4,6 +4,29 @@ Significant changes, features, and fixes in reverse chronological order. --- +## 2026-05-31 + +### Agent-scoped git credentials (issue #117) + +**New** + +- Added agent-scoped Secure CLI credentials with PostgreSQL migration `000077` + and SQLite schema version `46`. +- Added HTTP APIs under + `/v1/cli-credentials/{id}/agent-credentials/{agentId}` for listing, + reading metadata, saving, and deleting agent credentials. +- Web CLI Credentials now exposes Agent Credentials as the primary git PAT/SSH + setup path, with User Credentials renamed to advanced personal overrides. + +**Security** + +- Runtime credential precedence is now user override, context credential, agent + credential, then binary env defaults. +- Git adapter audit logs include `credential_source` without logging raw + secrets or plaintext host scopes. + +--- + ## 2026-05-29 ### Passive channel memory extraction (issue #64) diff --git a/internal/http/secure_cli.go b/internal/http/secure_cli.go index 1e3d51ba..98de702c 100644 --- a/internal/http/secure_cli.go +++ b/internal/http/secure_cli.go @@ -26,13 +26,22 @@ 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 - msgBus *bus.MessageBus + 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) *SecureCLIHandler { - return &SecureCLIHandler{store: s, msgBus: msgBus} +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. @@ -51,6 +60,13 @@ func (h *SecureCLIHandler) RegisterRoutes(mux *http.ServeMux) { 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 { diff --git a/internal/http/secure_cli_agent_credentials.go b/internal/http/secure_cli_agent_credentials.go new file mode 100644 index 00000000..ebe4115a --- /dev/null +++ b/internal/http/secure_cli_agent_credentials.go @@ -0,0 +1,217 @@ +package http + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type agentCredentialEntry struct { + ID uuid.UUID `json:"id"` + BinaryID uuid.UUID `json:"binary_id"` + AgentID uuid.UUID `json:"agent_id"` + AgentKey string `json:"agent_key,omitempty"` + Name string `json:"name,omitempty"` + HasSecret bool `json:"has_secret"` + 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"` + CreatedBy string `json:"created_by,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (h *SecureCLIHandler) requireAgentCredentialStore(w http.ResponseWriter, r *http.Request) (store.SecureCLIAgentCredentialStore, bool) { + if h.agentCreds == nil { + locale := store.LocaleFromContext(r.Context()) + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "agent credentials store unavailable")}) + return nil, false + } + return h.agentCreds, true +} + +func (h *SecureCLIHandler) requireAgentCredentialAdmin(w http.ResponseWriter, r *http.Request) bool { + return requireTenantAdmin(w, r, h.tenants) +} + +func agentCredentialResponse(c store.SecureCLIAgentCredential) agentCredentialEntry { + isTyped := c.CredentialType != nil && *c.CredentialType != "" && *c.CredentialType != "env" + e := agentCredentialEntry{ + ID: c.ID, + BinaryID: c.BinaryID, + AgentID: c.AgentID, + AgentKey: c.AgentKey, + Name: c.Name, + HasSecret: len(c.EncryptedEnv) > 0, + CredentialType: c.CredentialType, + HostScope: c.HostScope, + CreatedBy: c.CreatedBy, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + if !isTyped { + e.EnvKeys = envKeysFromDecryptedJSON(c.EncryptedEnv) + e.Env = store.SanitizeSecureCLIEnvJSON(c.EncryptedEnv) + } + return e +} + +func parseAgentCredentialPath(w http.ResponseWriter, r *http.Request, locale string) (uuid.UUID, uuid.UUID, bool) { + binaryID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")}) + return uuid.Nil, uuid.Nil, false + } + agentID, err := uuid.Parse(r.PathValue("agentId")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")}) + return uuid.Nil, uuid.Nil, false + } + return binaryID, agentID, true +} + +func (h *SecureCLIHandler) handleListAgentCredentials(w http.ResponseWriter, r *http.Request) { + if !h.requireAgentCredentialAdmin(w, r) { + return + } + agentCreds, ok := h.requireAgentCredentialStore(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")}) + return + } + creds, err := agentCreds.ListAgentCredentials(r.Context(), binaryID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + entries := make([]agentCredentialEntry, 0, len(creds)) + for _, c := range creds { + entries = append(entries, agentCredentialResponse(c)) + } + writeJSON(w, http.StatusOK, map[string]any{"agent_credentials": entries}) +} + +func (h *SecureCLIHandler) handleGetAgentCredentials(w http.ResponseWriter, r *http.Request) { + if !h.requireAgentCredentialAdmin(w, r) { + return + } + agentCreds, ok := h.requireAgentCredentialStore(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, agentID, ok := parseAgentCredentialPath(w, r, locale) + if !ok { + return + } + cred, err := agentCreds.GetAgentCredentials(r.Context(), binaryID, agentID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + if cred == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent credential", agentID.String())}) + return + } + writeJSON(w, http.StatusOK, agentCredentialResponse(*cred)) +} + +func (h *SecureCLIHandler) handleSetAgentCredentials(w http.ResponseWriter, r *http.Request) { + if !h.requireAgentCredentialAdmin(w, r) { + return + } + agentCreds, ok := h.requireAgentCredentialStore(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, agentID, ok := parseAgentCredentialPath(w, r, locale) + if !ok { + return + } + if exists, err := agentCreds.BinaryExists(r.Context(), binaryID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "validate credential")}) + return + } else if !exists { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "credential", binaryID.String())}) + return + } + if exists, err := agentCreds.AgentExists(r.Context(), agentID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "validate agent")}) + return + } else if !exists { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", agentID.String())}) + return + } + + var body struct { + Env json.RawMessage `json:"env"` + typedCredentialBody + } + if !bindJSON(w, r, locale, &body) { + return + } + envBytes, credType, hostScope, terr := prepareTypedCredentialEnv(locale, body.typedCredentialBody) + if terr != nil { + writeTypedCredentialError(w, terr) + return + } + createdBy := store.UserIDFromContext(r.Context()) + if envBytes != nil { + if err := agentCreds.SetAgentCredentialsTyped(r.Context(), binaryID, agentID, envBytes, credType, hostScope, createdBy); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + emitAudit(h.msgBus, r, "secure_cli.agent_credentials.updated", "secure_cli_agent_credentials", binaryID.String()+"/"+agentID.String()+"#"+*credType) + h.emitCacheInvalidate("") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return + } + if len(body.Env) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "env")}) + return + } + envJSON, ok := validateAndSerializeEnvVars(w, locale, body.Env) + if !ok { + return + } + if err := agentCreds.SetAgentCredentials(r.Context(), binaryID, agentID, envJSON, createdBy); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + emitAudit(h.msgBus, r, "secure_cli.agent_credentials.updated", "secure_cli_agent_credentials", binaryID.String()+"/"+agentID.String()) + h.emitCacheInvalidate("") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *SecureCLIHandler) handleDeleteAgentCredentials(w http.ResponseWriter, r *http.Request) { + if !h.requireAgentCredentialAdmin(w, r) { + return + } + agentCreds, ok := h.requireAgentCredentialStore(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, agentID, ok := parseAgentCredentialPath(w, r, locale) + if !ok { + return + } + if err := agentCreds.DeleteAgentCredentials(r.Context(), binaryID, agentID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + emitAudit(h.msgBus, r, "secure_cli.agent_credentials.deleted", "secure_cli_agent_credentials", binaryID.String()+"/"+agentID.String()) + h.emitCacheInvalidate("") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/http/secure_cli_typed_credentials_test.go b/internal/http/secure_cli_typed_credentials_test.go index bc0f822c..bcc99641 100644 --- a/internal/http/secure_cli_typed_credentials_test.go +++ b/internal/http/secure_cli_typed_credentials_test.go @@ -31,9 +31,16 @@ type recordingSecureCLIStore struct { lastTypedType *string lastTypedScope *string lastLegacyEnv []byte + lastAgentTypedEnv []byte + lastAgentType *string + lastAgentScope *string + lastAgentLegacy []byte typedCalls int legacyCalls int + agentTypedCalls int + agentLegacyCalls int existingForGet *store.SecureCLIUserCredential + existingAgent *store.SecureCLIAgentCredential } func (s *recordingSecureCLIStore) SetUserCredentialsTyped(_ context.Context, _ uuid.UUID, _ string, encryptedEnv []byte, credentialType, hostScope *string) error { @@ -58,6 +65,61 @@ func (s *recordingSecureCLIStore) GetUserCredentials(context.Context, uuid.UUID, return &cp, nil } +func (s *recordingSecureCLIStore) BinaryExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *recordingSecureCLIStore) AgentExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *recordingSecureCLIStore) SetAgentCredentialsTyped(_ context.Context, _ uuid.UUID, _ uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, _ string) error { + s.lastAgentTypedEnv = append([]byte(nil), encryptedEnv...) + s.lastAgentType = credentialType + s.lastAgentScope = hostScope + s.agentTypedCalls++ + return nil +} + +func (s *recordingSecureCLIStore) SetAgentCredentials(_ context.Context, _ uuid.UUID, _ uuid.UUID, encryptedEnv []byte, _ string) error { + s.lastAgentLegacy = append([]byte(nil), encryptedEnv...) + s.agentLegacyCalls++ + return nil +} + +func (s *recordingSecureCLIStore) GetAgentCredentials(context.Context, uuid.UUID, uuid.UUID) (*store.SecureCLIAgentCredential, error) { + if s.existingAgent == nil { + return nil, nil + } + cp := *s.existingAgent + return &cp, nil +} + +func (s *recordingSecureCLIStore) DeleteAgentCredentials(context.Context, uuid.UUID, uuid.UUID) error { + return nil +} + +func (s *recordingSecureCLIStore) ListAgentCredentials(context.Context, uuid.UUID) ([]store.SecureCLIAgentCredential, error) { + if s.existingAgent == nil { + return nil, nil + } + cp := *s.existingAgent + return []store.SecureCLIAgentCredential{cp}, nil +} + +func newAgentCredentialTestHandler(st *recordingSecureCLIStore) *SecureCLIHandler { + ts := newMockTenantStore() + ts.addTenant(store.MasterTenantID, "master") + ts.setUserRole(store.MasterTenantID, "system", store.TenantRoleAdmin) + return NewSecureCLIHandler(st, nil, ts) +} + +func withTenantAdminContext(req *http.Request) *http.Request { + ctx := store.WithTenantID(req.Context(), store.MasterTenantID) + ctx = store.WithUserID(ctx, "system") + return req.WithContext(ctx) +} + func putUserCred(t *testing.T, h *SecureCLIHandler, binaryID uuid.UUID, body any) *httptest.ResponseRecorder { t.Helper() buf, _ := json.Marshal(body) @@ -69,6 +131,17 @@ func putUserCred(t *testing.T, h *SecureCLIHandler, binaryID uuid.UUID, body any return rec } +func putAgentCred(t *testing.T, h *SecureCLIHandler, binaryID, agentID uuid.UUID, body any) *httptest.ResponseRecorder { + t.Helper() + buf, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/v1/cli-credentials/"+binaryID.String()+"/agent-credentials/"+agentID.String(), bytes.NewReader(buf)) + req.SetPathValue("id", binaryID.String()) + req.SetPathValue("agentId", agentID.String()) + rec := httptest.NewRecorder() + h.handleSetAgentCredentials(rec, withTenantAdminContext(req)) + return rec +} + // 9. Handler accepts new PAT payload and routes through SetUserCredentialsTyped. func TestPutUserCredential_PATPayload(t *testing.T) { st := &recordingSecureCLIStore{} @@ -106,6 +179,109 @@ func TestPutUserCredential_PATPayload(t *testing.T) { } } +func TestPutAgentCredential_PATPayload(t *testing.T) { + st := &recordingSecureCLIStore{} + h := newAgentCredentialTestHandler(st) + binaryID := uuid.New() + agentID := uuid.New() + + rec := putAgentCred(t, h, binaryID, agentID, map[string]any{ + "credential_type": "pat", + "host_scope": "github.com", + "blob": map[string]string{"token": "ghp_agentABC123456"}, + }) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if st.agentTypedCalls != 1 || st.agentLegacyCalls != 0 { + t.Fatalf("expected agent typed=1 legacy=0, got typed=%d legacy=%d", st.agentTypedCalls, st.agentLegacyCalls) + } + if st.lastAgentType == nil || *st.lastAgentType != "pat" { + t.Fatalf("type mismatch: %#v", st.lastAgentType) + } + if st.lastAgentScope == nil || *st.lastAgentScope != "github.com" { + t.Fatalf("scope mismatch: %#v", st.lastAgentScope) + } + var got map[string]string + if err := json.Unmarshal(st.lastAgentTypedEnv, &got); err != nil { + t.Fatal(err) + } + if got["token"] != "ghp_agentABC123456" { + t.Fatalf("stored token mismatch: %#v", got) + } + if strings.Contains(rec.Body.String(), "ghp_agent") { + t.Fatalf("response leaked token: %s", rec.Body.String()) + } +} + +func TestGetAgentCredential_TypedDoesNotReturnSecretBlob(t *testing.T) { + credType := "pat" + hostScope := "github.com" + binaryID := uuid.New() + agentID := uuid.New() + st := &recordingSecureCLIStore{existingAgent: &store.SecureCLIAgentCredential{ + ID: uuid.New(), + BinaryID: binaryID, + AgentID: agentID, + EncryptedEnv: []byte(`{"token":"ghp_secret_agent_token"}`), + CredentialType: &credType, + HostScope: &hostScope, + }} + h := newAgentCredentialTestHandler(st) + req := httptest.NewRequest(http.MethodGet, "/v1/cli-credentials/"+binaryID.String()+"/agent-credentials/"+agentID.String(), nil) + req.SetPathValue("id", binaryID.String()) + req.SetPathValue("agentId", agentID.String()) + rec := httptest.NewRecorder() + + h.handleGetAgentCredentials(rec, withTenantAdminContext(req)) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "ghp_secret") || strings.Contains(rec.Body.String(), "token") { + t.Fatalf("typed agent credential leaked blob material: %s", rec.Body.String()) + } +} + +func TestAgentCredentialsRouteRequiresTenantAdmin(t *testing.T) { + oldToken := pkgGatewayToken + oldFallback := pkgNoAuthFallbackAllowed + pkgGatewayToken = "" + pkgNoAuthFallbackAllowed = true + defer func() { + pkgGatewayToken = oldToken + pkgNoAuthFallbackAllowed = oldFallback + }() + + st := &recordingSecureCLIStore{} + ts := newMockTenantStore() + ts.addTenant(store.MasterTenantID, "master") + ts.setUserRole(store.MasterTenantID, "system", store.TenantRoleViewer) + h := NewSecureCLIHandler(st, nil, ts) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + binaryID := uuid.New() + agentID := uuid.New() + body, _ := json.Marshal(map[string]any{ + "credential_type": "pat", + "host_scope": "github.com", + "blob": map[string]string{"token": "ghp_routeABC123456"}, + }) + req := httptest.NewRequest(http.MethodPut, "/v1/cli-credentials/"+binaryID.String()+"/agent-credentials/"+agentID.String(), bytes.NewReader(body)) + req.Header.Set("X-GoClaw-User-Id", "browser-user") + rec := httptest.NewRecorder() + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if st.agentTypedCalls != 0 || st.agentLegacyCalls != 0 { + t.Fatalf("tenant viewer must not write credentials, typed=%d legacy=%d", st.agentTypedCalls, st.agentLegacyCalls) + } +} + // 10. Handler rejects passphrase-protected SSH key with error_key. func TestPutUserCredential_RejectsPassphraseKey(t *testing.T) { st := &recordingSecureCLIStore{} @@ -236,10 +412,7 @@ 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) - } + end := min(i+70, len(s)) if i > 0 { out.WriteByte('\n') } diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go index 02cb90fe..1b0191ac 100644 --- a/internal/store/pg/secure_cli.go +++ b/internal/store/pg/secure_cli.go @@ -355,6 +355,11 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string } else { selectCols += ", NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope" } + if agentID != nil { + selectCols += ", ac.encrypted_env AS agent_env, ac.credential_type AS agent_cred_type, ac.host_scope AS agent_host_scope" + } else { + selectCols += ", NULL AS agent_env, NULL AS agent_cred_type, NULL AS agent_host_scope" + } var args []any argIdx := 1 @@ -370,6 +375,16 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string } else { query += ` LEFT JOIN secure_cli_agent_grants g ON FALSE` // never match } + if agentID != nil { + query += fmt.Sprintf(` LEFT JOIN secure_cli_agent_credentials ac ON ac.binary_id = b.id AND ac.agent_id = $%d`, argIdx) + args = append(args, *agentID) + argIdx++ + if !isCross { + query += fmt.Sprintf(` AND ac.tenant_id = $%d`, argIdx) + args = append(args, tid) + argIdx++ + } + } // LEFT JOIN user credentials if userID != "" { @@ -432,6 +447,8 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu var grantEncEnv []byte var userEnv []byte var userCredType, userHostScope *string + var agentEnv []byte + var agentCredType, agentHostScope *string err := row.Scan( &b.ID, &b.BinaryName, &binaryPath, &b.Description, &env, @@ -442,6 +459,8 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu &grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantEnabled, &grantID, &grantEncEnv, // User credential columns &userEnv, &userCredType, &userHostScope, + // Agent credential columns + &agentEnv, &agentCredType, &agentHostScope, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -481,23 +500,45 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu grant.TimeoutSeconds = grantTimeout grant.Tips = grantTips // Decrypt grant env override (fail-closed: skip if decrypt fails). - if len(grantEncEnv) > 0 && s.encKey != "" { - if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil { - grant.EncryptedEnv = []byte(decrypted) + if len(grantEncEnv) > 0 { + if s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil { + grant.EncryptedEnv = []byte(decrypted) + } + } else { + grant.EncryptedEnv = grantEncEnv } } b.MergeGrantOverrides(grant) } // Decrypt per-user env - if len(userEnv) > 0 && s.encKey != "" { - if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil { - b.UserEnv = []byte(decrypted) + if len(userEnv) > 0 { + if s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil { + b.UserEnv = []byte(decrypted) + } + } else { + b.UserEnv = userEnv } } // Project per-user credential metadata so adapters can branch on it. b.UserCredentialType = userCredType b.UserHostScope = userHostScope + if len(b.UserEnv) > 0 || userCredType != nil || userHostScope != nil { + b.SetEffectiveCredential(b.UserEnv, userCredType, userHostScope, "user", "") + } + if len(agentEnv) > 0 || agentCredType != nil || agentHostScope != nil { + decrypted := agentEnv + if len(agentEnv) > 0 && s.encKey != "" { + if d, err := crypto.Decrypt(string(agentEnv), s.encKey); err == nil { + decrypted = []byte(d) + } + } + if b.CredentialSource == "" { + b.SetEffectiveCredential(decrypted, agentCredType, agentHostScope, "agent", "") + } + } return &b, nil } @@ -507,7 +548,7 @@ func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.S if len(scopes) == 0 || b == nil { return b, nil } - hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil + hasUserCredential := b.CredentialSource == "user" || len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil disabledByContext := false for _, scope := range scopes { grants, err := s.ListContextGrantsForScope(ctx, scope) @@ -546,9 +587,7 @@ func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.S continue } if !hasUserCredential { - b.UserEnv = creds.EncryptedEnv - b.UserCredentialType = creds.CredentialType - b.UserHostScope = creds.HostScope + b.SetEffectiveCredential(creds.EncryptedEnv, creds.CredentialType, creds.HostScope, "context", scope.ScopeType+":"+scope.ScopeKey) } else { b.EncryptedEnv = creds.EncryptedEnv } diff --git a/internal/store/pg/secure_cli_agent_credentials.go b/internal/store/pg/secure_cli_agent_credentials.go new file mode 100644 index 00000000..6d27d5a6 --- /dev/null +++ b/internal/store/pg/secure_cli_agent_credentials.go @@ -0,0 +1,179 @@ +package pg + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const agentCredSelectCols = `c.id, c.binary_id, c.agent_id, c.encrypted_env, c.metadata, + c.credential_type, c.host_scope, c.created_by, c.created_at, c.updated_at` + +func agentCredentialTenantID(ctx context.Context) (uuid.UUID, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return uuid.Nil, fmt.Errorf("tenant_id required for agent credentials") + } + return tid, nil +} + +func (s *PGSecureCLIStore) BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return false, nil + } + var exists bool + err = s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM secure_cli_binaries WHERE id = $1 AND tenant_id = $2)`, + binaryID, tid, + ).Scan(&exists) + return exists, err +} + +func (s *PGSecureCLIStore) AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return false, nil + } + var exists bool + err = s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM agents WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)`, + agentID, tid, + ).Scan(&exists) + return exists, err +} + +func (s *PGSecureCLIStore) GetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentCredential, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return nil, err + } + var c store.SecureCLIAgentCredential + var env []byte + err = s.db.QueryRowContext(ctx, + `SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM secure_cli_agent_credentials c + LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id + WHERE c.binary_id = $1 AND c.agent_id = $2 AND c.tenant_id = $3`, + binaryID, agentID, tid, + ).Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &c.Metadata, + &c.CredentialType, &c.HostScope, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + &c.AgentKey, &c.Name) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + c.EncryptedEnv = s.decryptAgentCredentialEnv(env) + return &c, nil +} + +func (s *PGSecureCLIStore) SetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error { + return s.SetAgentCredentialsTyped(ctx, binaryID, agentID, encryptedEnv, nil, nil, createdBy) +} + +func (s *PGSecureCLIStore) SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, createdBy string) error { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return err + } + envBytes, err := s.encryptAgentCredentialEnv(encryptedEnv) + if err != nil { + return err + } + now := time.Now() + res, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_agent_credentials + (binary_id, agent_id, encrypted_env, metadata, tenant_id, + credential_type, host_scope, created_by, created_at, updated_at) + SELECT b.id, a.id, $3, '{}', $4, $5, $6, $7, $8, $8 + FROM secure_cli_binaries b + JOIN agents a ON a.id = $2 AND a.tenant_id = $4 AND a.deleted_at IS NULL + WHERE b.id = $1 AND b.tenant_id = $4 + ON CONFLICT (binary_id, agent_id, tenant_id) DO UPDATE SET + encrypted_env = EXCLUDED.encrypted_env, + credential_type = EXCLUDED.credential_type, + host_scope = EXCLUDED.host_scope, + created_by = EXCLUDED.created_by, + updated_at = EXCLUDED.updated_at`, + binaryID, agentID, envBytes, tid, credentialType, hostScope, createdBy, now, + ) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err == nil && n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *PGSecureCLIStore) DeleteAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM secure_cli_agent_credentials WHERE binary_id = $1 AND agent_id = $2 AND tenant_id = $3`, + binaryID, agentID, tid, + ) + return err +} + +func (s *PGSecureCLIStore) ListAgentCredentials(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentCredential, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM secure_cli_agent_credentials c + LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id + WHERE c.binary_id = $1 AND c.tenant_id = $2 + ORDER BY c.created_at`, binaryID, tid) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.SecureCLIAgentCredential + for rows.Next() { + var c store.SecureCLIAgentCredential + var env []byte + if err := rows.Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &c.Metadata, + &c.CredentialType, &c.HostScope, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + &c.AgentKey, &c.Name); err != nil { + return nil, err + } + c.EncryptedEnv = s.decryptAgentCredentialEnv(env) + result = append(result, c) + } + return result, rows.Err() +} + +func (s *PGSecureCLIStore) encryptAgentCredentialEnv(env []byte) ([]byte, error) { + if len(env) == 0 || s.encKey == "" { + return env, nil + } + encrypted, err := crypto.Encrypt(string(env), s.encKey) + if err != nil { + return nil, fmt.Errorf("encrypt agent credential env: %w", err) + } + return []byte(encrypted), nil +} + +func (s *PGSecureCLIStore) decryptAgentCredentialEnv(env []byte) []byte { + if len(env) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil { + return []byte(decrypted) + } + } + return env +} diff --git a/internal/store/secure_cli_store.go b/internal/store/secure_cli_store.go index b3c9955f..f7b5555d 100644 --- a/internal/store/secure_cli_store.go +++ b/internal/store/secure_cli_store.go @@ -44,6 +44,13 @@ type SecureCLIBinary struct { // credential is legacy env-only. UserCredentialType *string `json:"-" db:"-"` UserHostScope *string `json:"-" db:"-"` + // CredentialEnv + metadata carry the effective typed credential selected + // for runtime injection. Source can be "user", "context", "agent", or "". + CredentialEnv []byte `json:"-" db:"-"` + CredentialType *string `json:"-" db:"-"` + CredentialHostScope *string `json:"-" db:"-"` + CredentialSource string `json:"credential_source,omitempty" db:"-"` + CredentialSubjectID string `json:"credential_subject_id,omitempty" 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. @@ -52,6 +59,25 @@ type SecureCLIBinary struct { AgentGrantsSummary []AgentGrantSummary `json:"agent_grants_summary" db:"-"` } +// SetEffectiveCredential records the credential material selected for runtime +// injection. The legacy User* fields remain populated for older call sites that +// still synthesize adapter credentials from the binary row. +func (b *SecureCLIBinary) SetEffectiveCredential(env []byte, credentialType, hostScope *string, source, subjectID string) { + if b == nil { + return + } + b.CredentialEnv = env + b.CredentialType = credentialType + b.CredentialHostScope = hostScope + b.CredentialSource = source + b.CredentialSubjectID = subjectID + if source == "user" || source == "context" { + b.UserEnv = env + b.UserCredentialType = credentialType + b.UserHostScope = hostScope + } +} + // MergeGrantOverrides applies agent grant overrides onto a binary config. // Non-nil grant fields replace binary defaults; nil fields keep binary values. func (b *SecureCLIBinary) MergeGrantOverrides(g *SecureCLIAgentGrant) { @@ -94,6 +120,25 @@ type SecureCLIUserCredential struct { HostScope *string `json:"host_scope,omitempty" db:"host_scope"` } +// SecureCLIAgentCredential holds per-agent encrypted env overrides for a binary. +type SecureCLIAgentCredential struct { + ID uuid.UUID `json:"id" db:"id"` + BinaryID uuid.UUID `json:"binary_id" db:"binary_id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + AgentKey string `json:"agent_key,omitempty" db:"-"` + Name string `json:"name,omitempty" db:"-"` + Metadata json.RawMessage `json:"metadata,omitempty" db:"metadata"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt string `json:"created_at" db:"created_at"` + 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. + CredentialType *string `json:"credential_type,omitempty" db:"credential_type"` + // HostScope binds the credential to a specific hostname. + HostScope *string `json:"host_scope,omitempty" db:"host_scope"` +} + // SecureCLIAgentGrant represents a per-agent grant with optional setting overrides. type SecureCLIAgentGrant struct { BaseModel @@ -158,6 +203,18 @@ type SecureCLIStore interface { ListUserCredentials(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIUserCredential, error) } +// SecureCLIAgentCredentialStore manages per-agent credential material for +// secure CLI binaries. It intentionally does not grant binary access. +type SecureCLIAgentCredentialStore interface { + BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) + AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error) + GetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*SecureCLIAgentCredential, error) + SetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error + SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, createdBy string) error + DeleteAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error + ListAgentCredentials(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIAgentCredential, error) +} + // SecureCLIAgentGrantStore manages per-agent grants for secure CLI binaries. type SecureCLIAgentGrantStore interface { BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index ac4e0193..e7791b79 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -16,7 +16,7 @@ var schemaSQL string // SchemaVersion is the current SQLite schema version. // Bump this when adding new migration steps below. -const SchemaVersion = 45 +const SchemaVersion = 46 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -807,6 +807,30 @@ CREATE INDEX IF NOT EXISTS idx_run_timeline_trace 43: addChannelContextCapabilityTables, // Version 44 → 45: passive channel memory extraction run and review queue. 44: addChannelMemoryExtractionTables, + // Version 45 → 46: per-agent typed Secure CLI credentials. + 45: `CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_binaries_id_tenant + ON secure_cli_binaries(id, tenant_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id_tenant + ON agents(id, tenant_id); +CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials ( + id TEXT NOT NULL PRIMARY KEY, + binary_id TEXT NOT NULL, + agent_id TEXT 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_by VARCHAR(255) NOT NULL DEFAULT '', + 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, agent_id, tenant_id), + FOREIGN KEY (binary_id, tenant_id) REFERENCES secure_cli_binaries(id, tenant_id) ON DELETE CASCADE, + FOREIGN KEY (agent_id, tenant_id) REFERENCES agents(id, tenant_id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id); +CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id); +CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id);`, } const addChannelMemoryExtractionTables = ` diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index c46fc0a3..28989c07 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -1710,6 +1710,36 @@ CREATE TABLE IF NOT EXISTS secure_cli_user_credentials ( CREATE INDEX IF NOT EXISTS idx_scuc_tenant ON secure_cli_user_credentials(tenant_id); CREATE INDEX IF NOT EXISTS idx_scuc_binary ON secure_cli_user_credentials(binary_id); +-- ============================================================ +-- Table: secure_cli_agent_credentials (per-agent encrypted env) +-- ============================================================ + +CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_binaries_id_tenant + ON secure_cli_binaries(id, tenant_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id_tenant + ON agents(id, tenant_id); + +CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials ( + id TEXT NOT NULL PRIMARY KEY, + binary_id TEXT NOT NULL, + agent_id TEXT 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_by VARCHAR(255) NOT NULL DEFAULT '', + 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, agent_id, tenant_id), + FOREIGN KEY (binary_id, tenant_id) REFERENCES secure_cli_binaries(id, tenant_id) ON DELETE CASCADE, + FOREIGN KEY (agent_id, tenant_id) REFERENCES agents(id, tenant_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id); +CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id); +CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id); + -- ============================================================ -- Table: vault_documents (V3 Knowledge Vault registry) -- ============================================================ diff --git a/internal/store/sqlitestore/secure-cli-agent-credentials.go b/internal/store/sqlitestore/secure-cli-agent-credentials.go new file mode 100644 index 00000000..9cd80425 --- /dev/null +++ b/internal/store/sqlitestore/secure-cli-agent-credentials.go @@ -0,0 +1,193 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const agentCredSelectCols = `c.id, c.binary_id, c.agent_id, c.encrypted_env, COALESCE(c.metadata, '{}'), + c.credential_type, c.host_scope, c.created_by, c.created_at, c.updated_at` + +func agentCredentialTenantID(ctx context.Context) (uuid.UUID, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return uuid.Nil, fmt.Errorf("tenant_id required for agent credentials") + } + return tid, nil +} + +func (s *SQLiteSecureCLIStore) BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return false, nil + } + var exists bool + err = s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM secure_cli_binaries WHERE id = ? AND tenant_id = ?)`, + binaryID, tid, + ).Scan(&exists) + return exists, err +} + +func (s *SQLiteSecureCLIStore) AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return false, nil + } + var exists bool + err = s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM agents WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL)`, + agentID, tid, + ).Scan(&exists) + return exists, err +} + +func (s *SQLiteSecureCLIStore) GetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentCredential, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return nil, err + } + var c store.SecureCLIAgentCredential + var env []byte + var metaBytes []byte + var createdAt, updatedAt string + err = s.db.QueryRowContext(ctx, + `SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM secure_cli_agent_credentials c + LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id + WHERE c.binary_id = ? AND c.agent_id = ? AND c.tenant_id = ?`, + binaryID, agentID, tid, + ).Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &metaBytes, + &c.CredentialType, &c.HostScope, &c.CreatedBy, &createdAt, &updatedAt, + &c.AgentKey, &c.Name) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + c.CreatedAt = createdAt + c.UpdatedAt = updatedAt + c.Metadata = metaBytes + c.EncryptedEnv = s.decryptAgentCredentialEnv(env) + return &c, nil +} + +func (s *SQLiteSecureCLIStore) SetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error { + return s.SetAgentCredentialsTyped(ctx, binaryID, agentID, encryptedEnv, nil, nil, createdBy) +} + +func (s *SQLiteSecureCLIStore) SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, createdBy string) error { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return err + } + envBytes, err := s.encryptAgentCredentialEnv(encryptedEnv) + if err != nil { + return err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + id := store.GenNewID() + res, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_agent_credentials + (id, binary_id, agent_id, encrypted_env, metadata, tenant_id, + credential_type, host_scope, created_by, created_at, updated_at) + SELECT ?, b.id, a.id, ?, '{}', ?, ?, ?, ?, ?, ? + FROM secure_cli_binaries b + JOIN agents a ON a.id = ? AND a.tenant_id = ? AND a.deleted_at IS NULL + WHERE b.id = ? AND b.tenant_id = ? + ON CONFLICT (binary_id, agent_id, tenant_id) DO UPDATE SET + encrypted_env = excluded.encrypted_env, + credential_type = excluded.credential_type, + host_scope = excluded.host_scope, + created_by = excluded.created_by, + updated_at = excluded.updated_at`, + id, envBytes, tid, credentialType, hostScope, createdBy, now, now, + agentID, tid, binaryID, tid, + ) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err == nil && n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *SQLiteSecureCLIStore) DeleteAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM secure_cli_agent_credentials WHERE binary_id = ? AND agent_id = ? AND tenant_id = ?`, + binaryID, agentID, tid, + ) + return err +} + +func (s *SQLiteSecureCLIStore) ListAgentCredentials(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentCredential, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM secure_cli_agent_credentials c + LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id + WHERE c.binary_id = ? AND c.tenant_id = ? + ORDER BY c.created_at`, binaryID, tid) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.SecureCLIAgentCredential + for rows.Next() { + var c store.SecureCLIAgentCredential + var env []byte + var metaBytes []byte + var createdAt, updatedAt string + if err := rows.Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &metaBytes, + &c.CredentialType, &c.HostScope, &c.CreatedBy, &createdAt, &updatedAt, + &c.AgentKey, &c.Name); err != nil { + return nil, err + } + c.CreatedAt = createdAt + c.UpdatedAt = updatedAt + c.Metadata = metaBytes + c.EncryptedEnv = s.decryptAgentCredentialEnv(env) + result = append(result, c) + } + return result, rows.Err() +} + +func (s *SQLiteSecureCLIStore) encryptAgentCredentialEnv(env []byte) ([]byte, error) { + if len(env) == 0 || s.encKey == "" { + return env, nil + } + encrypted, err := crypto.Encrypt(string(env), s.encKey) + if err != nil { + return nil, fmt.Errorf("encrypt agent credential env: %w", err) + } + return []byte(encrypted), nil +} + +func (s *SQLiteSecureCLIStore) decryptAgentCredentialEnv(env []byte) []byte { + if len(env) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil { + return []byte(decrypted) + } + } + return env +} diff --git a/internal/store/sqlitestore/secure-cli.go b/internal/store/sqlitestore/secure-cli.go index a3fe20dc..85a69521 100644 --- a/internal/store/sqlitestore/secure-cli.go +++ b/internal/store/sqlitestore/secure-cli.go @@ -381,8 +381,6 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st var args []any - 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. @@ -390,10 +388,17 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st 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` + selectCols += `, uc_user.encrypted_env AS user_env, uc_user.credential_type AS user_cred_type, uc_user.host_scope AS user_host_scope` } else { - query += `, NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope FROM secure_cli_binaries b` + selectCols += `, NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope` } + if agentID != nil { + selectCols += `, ac.encrypted_env AS agent_env, ac.credential_type AS agent_cred_type, ac.host_scope AS agent_host_scope` + } else { + selectCols += `, NULL AS agent_env, NULL AS agent_cred_type, NULL AS agent_host_scope` + } + + query := `SELECT ` + selectCols + ` FROM secure_cli_binaries b` // LEFT JOIN agent grant if agentID != nil { @@ -402,6 +407,14 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st } else { query += ` LEFT JOIN secure_cli_agent_grants g ON 0` } + if agentID != nil { + query += ` LEFT JOIN secure_cli_agent_credentials ac ON ac.binary_id = b.id AND ac.agent_id = ?` + args = append(args, *agentID) + if !isCross { + query += ` AND ac.tenant_id = ?` + args = append(args, tid) + } + } // LEFT JOIN user credentials (only when we project uc_user.*) if hasUserJoin || hasUserJoinNoAgent { @@ -457,6 +470,8 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store. var grantEncEnv []byte var userEnv []byte var userCredType, userHostScope *string + var agentEnv []byte + var agentCredType, agentHostScope *string var createdAt, updatedAt sqliteTime err := row.Scan( @@ -466,6 +481,7 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store. &b.Enabled, &b.CreatedBy, &b.AdapterName, &createdAt, &updatedAt, &grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantEnabled, &grantID, &grantEncEnv, &userEnv, &userCredType, &userHostScope, + &agentEnv, &agentCredType, &agentHostScope, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -506,24 +522,46 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store. } grant.TimeoutSeconds = grantTimeout grant.Tips = grantTips - if len(grantEncEnv) > 0 && s.encKey != "" { - if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil { - grant.EncryptedEnv = []byte(decrypted) + if len(grantEncEnv) > 0 { + if s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil { + grant.EncryptedEnv = []byte(decrypted) + } + } else { + grant.EncryptedEnv = grantEncEnv } } b.MergeGrantOverrides(grant) } // Decrypt per-user env - if len(userEnv) > 0 && s.encKey != "" { - if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil { - b.UserEnv = []byte(decrypted) + if len(userEnv) > 0 { + if s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil { + b.UserEnv = []byte(decrypted) + } + } else { + b.UserEnv = userEnv } } // Typed-credential metadata from the joined user-credential row. b.UserCredentialType = userCredType b.UserHostScope = userHostScope + if len(b.UserEnv) > 0 || userCredType != nil || userHostScope != nil { + b.SetEffectiveCredential(b.UserEnv, userCredType, userHostScope, "user", "") + } + if len(agentEnv) > 0 || agentCredType != nil || agentHostScope != nil { + decrypted := agentEnv + if len(agentEnv) > 0 && s.encKey != "" { + if d, err := crypto.Decrypt(string(agentEnv), s.encKey); err == nil { + decrypted = []byte(d) + } + } + if b.CredentialSource == "" { + b.SetEffectiveCredential(decrypted, agentCredType, agentHostScope, "agent", "") + } + } return &b, nil } @@ -533,7 +571,7 @@ func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *sto if len(scopes) == 0 || b == nil { return b, nil } - hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil + hasUserCredential := b.CredentialSource == "user" || len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil disabledByContext := false for _, scope := range scopes { grants, err := s.ListContextGrantsForScope(ctx, scope) @@ -572,9 +610,7 @@ func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *sto continue } if !hasUserCredential { - b.UserEnv = creds.EncryptedEnv - b.UserCredentialType = creds.CredentialType - b.UserHostScope = creds.HostScope + b.SetEffectiveCredential(creds.EncryptedEnv, creds.CredentialType, creds.HostScope, "context", scope.ScopeType+":"+scope.ScopeKey) } else { b.EncryptedEnv = creds.EncryptedEnv } diff --git a/internal/store/sqlitestore/secure_cli_schema_test.go b/internal/store/sqlitestore/secure_cli_schema_test.go index d989d0ec..9a821157 100644 --- a/internal/store/sqlitestore/secure_cli_schema_test.go +++ b/internal/store/sqlitestore/secure_cli_schema_test.go @@ -5,6 +5,7 @@ package sqlitestore import ( "context" "database/sql" + "errors" "strings" "testing" @@ -75,11 +76,11 @@ func TestSQLite_CreateBinary_AdapterNameRoundTrip(t *testing.T) { adapter := "git" bin := &store.SecureCLIBinary{ - BinaryName: "git", - Description: "git with PAT adapter", - IsGlobal: true, - Enabled: true, - CreatedBy: "u-tester", + BinaryName: "git", + Description: "git with PAT adapter", + IsGlobal: true, + Enabled: true, + CreatedBy: "u-tester", AdapterName: &adapter, EncryptedEnv: []byte(`{}`), } @@ -206,11 +207,11 @@ func TestSQLite_LookupByBinary_ProjectsNewColumns(t *testing.T) { adapter := "git" bin := &store.SecureCLIBinary{ - BinaryName: "git", - Description: "git adapter binary", - IsGlobal: true, - Enabled: true, - CreatedBy: "u-tester", + BinaryName: "git", + Description: "git adapter binary", + IsGlobal: true, + Enabled: true, + CreatedBy: "u-tester", AdapterName: &adapter, EncryptedEnv: []byte(`{}`), } @@ -268,3 +269,169 @@ func TestSQLite_LookupByBinary_BackwardCompatibleNulls(t *testing.T) { t.Fatalf("expected UserHostScope NULL, got %q", *got.UserHostScope) } } + +func seedAgent(t *testing.T, db *sql.DB, tenantID uuid.UUID, key string) uuid.UUID { + t.Helper() + id := uuid.New() + _, err := db.Exec( + `INSERT INTO agents (id, tenant_id, agent_key, display_name, owner_id, provider, model, agent_type, status) + VALUES (?, ?, ?, ?, 'owner', 'openai', 'gpt-5', 'predefined', 'active')`, + id, tenantID, key, key, + ) + if err != nil { + t.Fatalf("seed agent %s: %v", key, err) + } + return id +} + +func TestSQLite_SetAgentCredentialsTyped_RoundTrip(t *testing.T) { + s, db := newPhase1Store(t) + tid := seedTenant(t, db, "t-agent-typed") + ctx := store.WithTenantID(context.Background(), tid) + agentID := seedAgent(t, db, tid, "builder") + + 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) + } + + credType := "pat" + hostScope := "github.com" + plaintext := []byte(`{"token":"ghp_agent"}`) + if err := s.SetAgentCredentialsTyped(ctx, binID, agentID, plaintext, &credType, &hostScope, "admin"); err != nil { + t.Fatalf("SetAgentCredentialsTyped: %v", err) + } + + got, err := s.GetAgentCredentials(ctx, binID, agentID) + if err != nil { + t.Fatalf("GetAgentCredentials: %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) + } +} + +func TestSQLite_LookupByBinary_UsesAgentCredentialWithoutUserID(t *testing.T) { + s, db := newPhase1Store(t) + tid := seedTenant(t, db, "t-agent-lookup") + ctx := store.WithTenantID(context.Background(), tid) + agentID := seedAgent(t, db, tid, "builder") + + 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.SetAgentCredentialsTyped(ctx, bin.ID, agentID, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin"); err != nil { + t.Fatalf("SetAgentCredentialsTyped: %v", err) + } + + got, err := s.LookupByBinary(ctx, "git", &agentID, "") + if err != nil { + t.Fatalf("LookupByBinary: %v", err) + } + if got == nil { + t.Fatalf("expected binary, got nil") + } + if got.CredentialSource != "agent" { + t.Fatalf("expected agent source, got %q", got.CredentialSource) + } + if got.CredentialType == nil || *got.CredentialType != "pat" { + t.Fatalf("expected CredentialType=pat, got %v", got.CredentialType) + } + if string(got.CredentialEnv) != `{"token":"agent"}` { + t.Fatalf("expected agent credential env, got %q", got.CredentialEnv) + } +} + +func TestSQLite_LookupByBinary_UserCredentialOverridesAgentCredential(t *testing.T) { + s, db := newPhase1Store(t) + tid := seedTenant(t, db, "t-user-over-agent") + ctx := store.WithTenantID(context.Background(), tid) + agentID := seedAgent(t, db, tid, "builder") + + 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.SetAgentCredentialsTyped(ctx, bin.ID, agentID, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin"); err != nil { + t.Fatalf("SetAgentCredentialsTyped: %v", err) + } + if err := s.SetUserCredentialsTyped(ctx, bin.ID, "u-1", []byte(`{"token":"user"}`), &credType, &hostScope); err != nil { + t.Fatalf("SetUserCredentialsTyped: %v", err) + } + + got, err := s.LookupByBinary(ctx, "git", &agentID, "u-1") + if err != nil { + t.Fatalf("LookupByBinary: %v", err) + } + if got == nil { + t.Fatalf("expected binary, got nil") + } + if got.CredentialSource != "user" { + t.Fatalf("expected user source, got %q", got.CredentialSource) + } + if string(got.CredentialEnv) != `{"token":"user"}` { + t.Fatalf("expected user credential env, got %q", got.CredentialEnv) + } +} + +func TestSQLite_SetAgentCredentialsRejectsCrossTenantAgent(t *testing.T) { + s, db := newPhase1Store(t) + tenantA := seedTenant(t, db, "t-agent-cred-a") + tenantB := seedTenant(t, db, "t-agent-cred-b") + ctxA := store.WithTenantID(context.Background(), tenantA) + agentB := seedAgent(t, db, tenantB, "other-tenant-agent") + + bin := &store.SecureCLIBinary{ + BinaryName: "git", + Description: "git adapter binary", + IsGlobal: true, + Enabled: true, + CreatedBy: "u-tester", + EncryptedEnv: []byte(`{}`), + } + if err := s.Create(ctxA, bin); err != nil { + t.Fatalf("Create: %v", err) + } + + credType := "pat" + hostScope := "github.com" + err := s.SetAgentCredentialsTyped(ctxA, bin.ID, agentB, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin") + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected sql.ErrNoRows for cross-tenant agent, got %v", err) + } +} diff --git a/internal/tools/credential_adapter.go b/internal/tools/credential_adapter.go index a5d9112e..fb95d49e 100644 --- a/internal/tools/credential_adapter.go +++ b/internal/tools/credential_adapter.go @@ -80,8 +80,8 @@ func AdapterFor(name string) CredentialAdapter { // 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) 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 } @@ -120,16 +120,18 @@ func sortedKeys(m map[string]string) []string { // - 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) +// - credential_source: source selected by runtime ("user", "context", "agent", or empty) // - 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) { +func emitSystemEnvInjectionAudit(adapter, binary, userID, credentialSource string, inj *Injection, hostScope *string) { if inj == nil { return } slog.Warn("security.system_env_injection", "adapter", adapter, "user_id", userID, + "credential_source", credentialSource, "binary", binary, "env_keys", sortedKeys(inj.Env), "argv_prefix_len", len(inj.ArgvPrefix), diff --git a/internal/tools/credential_audit_log_test.go b/internal/tools/credential_audit_log_test.go index b878b2d0..b466a63c 100644 --- a/internal/tools/credential_audit_log_test.go +++ b/internal/tools/credential_audit_log_test.go @@ -62,7 +62,7 @@ func TestEmitSystemEnvInjectionAudit_PAT(t *testing.T) { } records, raw := capturedAudit(t, func() { - emitSystemEnvInjectionAudit("git", "git", "user-42", inj, &scope) + emitSystemEnvInjectionAudit("git", "git", "user-42", "agent", inj, &scope) }) if len(records) != 1 { @@ -81,6 +81,9 @@ func TestEmitSystemEnvInjectionAudit_PAT(t *testing.T) { if rec["user_id"] != "user-42" { t.Errorf("user_id=%v, want user-42", rec["user_id"]) } + if rec["credential_source"] != "agent" { + t.Errorf("credential_source=%v, want agent", rec["credential_source"]) + } if rec["argv_prefix_len"] != float64(0) { t.Errorf("argv_prefix_len=%v, want 0", rec["argv_prefix_len"]) } @@ -132,7 +135,7 @@ func TestEmitSystemEnvInjectionAudit_SSH(t *testing.T) { } records, raw := capturedAudit(t, func() { - emitSystemEnvInjectionAudit("git", "git", "u1", inj, &scope) + emitSystemEnvInjectionAudit("git", "git", "u1", "user", inj, &scope) }) if len(records) != 1 { @@ -161,7 +164,7 @@ func TestEmitSystemEnvInjectionAudit_SSH(t *testing.T) { // 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) + emitSystemEnvInjectionAudit("git", "git", "u1", "user", nil, nil) }) if len(records) != 0 { t.Fatalf("expected 0 records for nil injection, got %d", len(records)) @@ -172,7 +175,7 @@ func TestEmitSystemEnvInjectionAudit_NilInjection(t *testing.T) { func TestEmitSystemEnvInjectionAudit_NilHostScope(t *testing.T) { inj := &Injection{Env: map[string]string{"FOO": "bar"}} records, _ := capturedAudit(t, func() { - emitSystemEnvInjectionAudit("passthrough", "gh", "u1", inj, nil) + emitSystemEnvInjectionAudit("passthrough", "gh", "u1", "", inj, nil) }) if len(records) != 1 { t.Fatalf("expected 1 record, got %d", len(records)) diff --git a/internal/tools/credentialed_exec.go b/internal/tools/credentialed_exec.go index 200732e3..916e0a36 100644 --- a/internal/tools/credentialed_exec.go +++ b/internal/tools/credentialed_exec.go @@ -490,14 +490,12 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL if len(inj.ArgvPrefix) > 0 { args = append(append([]string{}, inj.ArgvPrefix...), args...) } - for k, v := range inj.Env { - envMap[k] = v - } + maps.Copy(envMap, inj.Env) if len(inj.ScrubValues) > 0 { AddScrubValuesCtx(ctx, inj.ScrubValues...) } emitSystemEnvInjectionAudit(adapter.Name(), binary, - store.CredentialUserIDFromContext(ctx), inj, cred.UserHostScope) + store.CredentialUserIDFromContext(ctx), cred.CredentialSource, inj, effectiveHostScope(cred)) } } @@ -508,13 +506,23 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL 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). +// userCredFromBinary synthesizes adapter credential input from LookupByBinary's +// effective credential fields. The adapter contract still uses +// SecureCLIUserCredential, but the material can come from user, context, or +// agent scoped rows. func userCredFromBinary(ctx context.Context, bin *store.SecureCLIBinary) *store.SecureCLIUserCredential { if bin == nil { return nil } + if len(bin.CredentialEnv) > 0 || bin.CredentialType != nil || bin.CredentialHostScope != nil { + return &store.SecureCLIUserCredential{ + BinaryID: bin.ID, + UserID: credentialSubjectForAdapter(ctx, bin), + EncryptedEnv: bin.CredentialEnv, + CredentialType: bin.CredentialType, + HostScope: bin.CredentialHostScope, + } + } if len(bin.UserEnv) == 0 && bin.UserCredentialType == nil && bin.UserHostScope == nil { return nil } @@ -527,6 +535,23 @@ func userCredFromBinary(ctx context.Context, bin *store.SecureCLIBinary) *store. } } +func credentialSubjectForAdapter(ctx context.Context, bin *store.SecureCLIBinary) string { + if bin != nil && bin.CredentialSubjectID != "" { + return bin.CredentialSubjectID + } + return store.CredentialUserIDFromContext(ctx) +} + +func effectiveHostScope(bin *store.SecureCLIBinary) *string { + if bin == nil { + return nil + } + if bin.CredentialHostScope != nil { + return bin.CredentialHostScope + } + return bin.UserHostScope +} + func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error) { envMap := make(map[string]string) if cred == nil { @@ -539,7 +564,18 @@ func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error } maps.Copy(envMap, baseEnv) } + if len(cred.CredentialEnv) > 0 && isEnvCredentialType(cred.CredentialType) { + scopedEnv, err := store.FlattenSecureCLIEnv(cred.CredentialEnv) + if err != nil { + return nil, err + } + maps.Copy(envMap, scopedEnv) + return envMap, nil + } if len(cred.UserEnv) > 0 { + if !isEnvCredentialType(cred.UserCredentialType) { + return envMap, nil + } userEnvMap, err := store.FlattenSecureCLIEnv(cred.UserEnv) if err != nil { return nil, err @@ -549,6 +585,10 @@ func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error return envMap, nil } +func isEnvCredentialType(typ *string) bool { + return typ == nil || *typ == "" || *typ == "env" +} + func missingRequiredCredentialEnv(binary string, envMap map[string]string) []string { required := requiredCredentialEnvVars(binary) if len(required) == 0 { diff --git a/internal/tools/credentialed_exec_env_test.go b/internal/tools/credentialed_exec_env_test.go index 7186d15e..daaca6d7 100644 --- a/internal/tools/credentialed_exec_env_test.go +++ b/internal/tools/credentialed_exec_env_test.go @@ -73,6 +73,44 @@ func TestMergeCredentialedEnvFlattensSensitiveValueEntries(t *testing.T) { } } +func TestMergeCredentialedEnvUsesAgentEnvCredential(t *testing.T) { + typ := "env" + binary := &store.SecureCLIBinary{ + EncryptedEnv: []byte(`{"SHARED_KEY":"binary","BINARY_ONLY":"base"}`), + } + binary.SetEffectiveCredential([]byte(`{"SHARED_KEY":"agent","AGENT_ONLY":"scoped"}`), &typ, nil, "agent", "") + + env, err := mergeCredentialedEnv(binary) + if err != nil { + t.Fatalf("mergeCredentialedEnv() error = %v", err) + } + if env["SHARED_KEY"] != "agent" { + t.Fatalf("SHARED_KEY = %q, want agent override", env["SHARED_KEY"]) + } + if env["AGENT_ONLY"] != "scoped" { + t.Fatalf("AGENT_ONLY = %q", env["AGENT_ONLY"]) + } +} + +func TestMergeCredentialedEnvDoesNotFlattenTypedCredentialBlob(t *testing.T) { + typ := "pat" + binary := &store.SecureCLIBinary{ + EncryptedEnv: []byte(`{"PUBLIC_BASE_URL":"https://goclaw.sh"}`), + } + binary.SetEffectiveCredential([]byte(`{"token":"ghp_not_real_token"}`), &typ, nil, "agent", "") + + env, err := mergeCredentialedEnv(binary) + if err != nil { + t.Fatalf("mergeCredentialedEnv() error = %v", err) + } + if _, ok := env["token"]; ok { + t.Fatalf("typed credential blob was flattened into child env: %#v", env) + } + if env["PUBLIC_BASE_URL"] != "https://goclaw.sh" { + t.Fatalf("PUBLIC_BASE_URL = %q", env["PUBLIC_BASE_URL"]) + } +} + func TestExec_RapidAPIMissingRequiredEnvFailsBeforeBinaryResolution(t *testing.T) { stub := newStubSecureCLIStore() stub.byName["rapidapi"] = &store.SecureCLIBinary{ diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index af197a28..36f249f9 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -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 = 76 +const RequiredSchemaVersion uint = 77 diff --git a/migrations/000077_secure_cli_agent_credentials.down.sql b/migrations/000077_secure_cli_agent_credentials.down.sql new file mode 100644 index 00000000..a57de9cf --- /dev/null +++ b/migrations/000077_secure_cli_agent_credentials.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS secure_cli_agent_credentials; +DROP INDEX IF EXISTS idx_agents_id_tenant; +DROP INDEX IF EXISTS idx_secure_cli_binaries_id_tenant; diff --git a/migrations/000077_secure_cli_agent_credentials.up.sql b/migrations/000077_secure_cli_agent_credentials.up.sql new file mode 100644 index 00000000..fab74867 --- /dev/null +++ b/migrations/000077_secure_cli_agent_credentials.up.sql @@ -0,0 +1,29 @@ +-- Per-agent credentials for secure CLI binaries. +-- Stores typed secret material separately from secure_cli_agent_grants policy. +CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_binaries_id_tenant + ON secure_cli_binaries(id, tenant_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id_tenant + ON agents(id, tenant_id); + +CREATE TABLE secure_cli_agent_credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + binary_id UUID NOT NULL, + agent_id UUID NOT NULL, + encrypted_env BYTEA NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + tenant_id UUID NOT NULL REFERENCES tenants(id), + credential_type TEXT NULL, + host_scope TEXT NULL, + created_by VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(binary_id, agent_id, tenant_id), + CONSTRAINT fk_scac_binary_tenant + FOREIGN KEY (binary_id, tenant_id) REFERENCES secure_cli_binaries(id, tenant_id) ON DELETE CASCADE, + CONSTRAINT fk_scac_agent_tenant + FOREIGN KEY (agent_id, tenant_id) REFERENCES agents(id, tenant_id) ON DELETE CASCADE +); + +CREATE INDEX idx_scac_tenant ON secure_cli_agent_credentials(tenant_id); +CREATE INDEX idx_scac_binary ON secure_cli_agent_credentials(binary_id); +CREATE INDEX idx_scac_agent ON secure_cli_agent_credentials(agent_id); diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md b/plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md index a1b76117..0a139101 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md @@ -1,8 +1,8 @@ --- phase: 1 -title: "Research and contract tests" -status: pending -effort: "" +title: Research and contract tests +status: completed +effort: '' --- # Phase 1: Research and contract tests diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md b/plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md index 088bc698..f67aff06 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md @@ -1,8 +1,8 @@ --- phase: 2 -title: "Schema and store resolver" -status: pending -effort: "" +title: Schema and store resolver +status: completed +effort: '' --- # Phase 2: Schema and store resolver diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md b/plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md index da29401b..898a7953 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md @@ -1,8 +1,8 @@ --- phase: 3 -title: "HTTP API credential management" -status: pending -effort: "" +title: HTTP API credential management +status: completed +effort: '' --- # Phase 3: HTTP API credential management diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md b/plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md index 3d6368c4..985b8531 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md @@ -1,8 +1,8 @@ --- phase: 4 -title: "Web UI credential management" -status: pending -effort: "" +title: Web UI credential management +status: completed +effort: '' --- # Phase 4: Web UI credential management diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md b/plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md index 28b209bb..fce45f32 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md @@ -1,8 +1,8 @@ --- phase: 5 -title: "Runtime git adapter validation" -status: pending -effort: "" +title: Runtime git adapter validation +status: completed +effort: '' --- # Phase 5: Runtime git adapter validation diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md b/plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md index 3cb5e7e2..b634b4ac 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md @@ -1,8 +1,8 @@ --- phase: 6 -title: "Docs validation and handoff" -status: pending -effort: "" +title: Docs validation and handoff +status: completed +effort: '' --- # Phase 6: Docs validation and handoff diff --git a/plans/260531-1545-agent-scoped-git-credentials/plan.md b/plans/260531-1545-agent-scoped-git-credentials/plan.md index aacfd4b6..7ea87305 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/plan.md +++ b/plans/260531-1545-agent-scoped-git-credentials/plan.md @@ -1,15 +1,17 @@ --- -title: "Agent-scoped Git credentials" -description: "TDD plan for moving git typed credentials from channel/user-id keyed defaults to agent-scoped credentials, with HTTP API and Web UI management." -status: pending +title: Agent-scoped Git credentials +description: >- + TDD plan for moving git typed credentials from channel/user-id keyed defaults + to agent-scoped credentials, with HTTP API and Web UI management. +status: completed priority: P1 issue: 117 -branch: "codex/issue-117-agent-scoped-git-credentials-plan" +branch: codex/issue-117-agent-scoped-git-credentials-plan tags: [] blockedBy: [] blocks: [] -created: "2026-05-31T08:45:33.071Z" -createdBy: "ck:plan" +created: '2026-05-31T08:45:33.071Z' +createdBy: 'ck:plan' source: skill --- @@ -33,12 +35,12 @@ TDD target: | Phase | Name | Status | |-------|------|--------| -| 1 | [Research and contract tests](./phase-01-research-and-contract-tests.md) | Pending | -| 2 | [Schema and store resolver](./phase-02-schema-and-store-resolver.md) | Pending | -| 3 | [HTTP API credential management](./phase-03-http-api-credential-management.md) | Pending | -| 4 | [Web UI credential management](./phase-04-web-ui-credential-management.md) | Pending | -| 5 | [Runtime git adapter validation](./phase-05-runtime-git-adapter-validation.md) | Pending | -| 6 | [Docs validation and handoff](./phase-06-docs-validation-and-handoff.md) | Pending | +| 1 | [Research and contract tests](./phase-01-research-and-contract-tests.md) | Completed | +| 2 | [Schema and store resolver](./phase-02-schema-and-store-resolver.md) | Completed | +| 3 | [HTTP API credential management](./phase-03-http-api-credential-management.md) | Completed | +| 4 | [Web UI credential management](./phase-04-web-ui-credential-management.md) | Completed | +| 5 | [Runtime git adapter validation](./phase-05-runtime-git-adapter-validation.md) | Completed | +| 6 | [Docs validation and handoff](./phase-06-docs-validation-and-handoff.md) | Completed | ## Dependencies diff --git a/ui/web/src/i18n/locales/en/cli-credentials.json b/ui/web/src/i18n/locales/en/cli-credentials.json index 9f73cd5e..77f53bcb 100644 --- a/ui/web/src/i18n/locales/en/cli-credentials.json +++ b/ui/web/src/i18n/locales/en/cli-credentials.json @@ -70,9 +70,27 @@ "deleted": "CLI credential deleted", "deleteFailed": "Failed to delete credential" }, + "agentCredentials": { + "title": "Agent Credentials", + "description": "Configure PAT, SSH key, or env credentials that {{name}} can use when a selected agent runs it.", + "securityHint": "Anyone with access to the selected agent can cause that agent to use this credential.", + "agent": "Agent", + "env": "Environment Variables", + "add": "Add Agent Credential", + "save": "Save", + "edit": "Edit", + "delete": "Delete", + "back": "Back", + "empty": "No agent credentials configured", + "saved": "Agent credentials saved", + "saveFailed": "Failed to save agent credentials", + "deleted": "Agent credentials deleted", + "deleteFailed": "Failed to delete agent credentials", + "envRequired": "At least one environment variable is required" + }, "userCredentials": { - "title": "User Credentials", - "description": "Per-user environment variable overrides for {{name}}", + "title": "Advanced User Overrides", + "description": "Optional per-user overrides for {{name}}. Use Agent Credentials for normal git PAT or SSH setup.", "userId": "User ID", "userIdPlaceholder": "user-id or email", "env": "Environment Variables", @@ -89,7 +107,7 @@ "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": "Use only for personal overrides. Chat users (Telegram, Discord, etc.) must be merged into a tenant user first via Contacts page.", "credentialType": "Credential Type", "credentialTypeEnv": "Environment Variables (legacy)", "credentialTypePAT": "Personal Access Token (PAT)", diff --git a/ui/web/src/i18n/locales/vi/cli-credentials.json b/ui/web/src/i18n/locales/vi/cli-credentials.json index 8538ccd2..76f65515 100644 --- a/ui/web/src/i18n/locales/vi/cli-credentials.json +++ b/ui/web/src/i18n/locales/vi/cli-credentials.json @@ -70,9 +70,27 @@ "deleted": "Đã xóa thông tin CLI", "deleteFailed": "Không thể xóa thông tin" }, + "agentCredentials": { + "title": "Credential theo agent", + "description": "Cấu hình PAT, khóa SSH hoặc biến môi trường để {{name}} dùng khi agent được chọn chạy lệnh.", + "securityHint": "Bất kỳ ai có quyền dùng agent được chọn đều có thể khiến agent đó dùng credential này.", + "agent": "Agent", + "env": "Biến môi trường", + "add": "Thêm credential agent", + "save": "Lưu", + "edit": "Sửa", + "delete": "Xóa", + "back": "Quay lại", + "empty": "Chưa có credential theo agent", + "saved": "Đã lưu credential agent", + "saveFailed": "Không thể lưu credential agent", + "deleted": "Đã xóa credential agent", + "deleteFailed": "Không thể xóa credential agent", + "envRequired": "Cần ít nhất một biến môi trường" + }, "userCredentials": { - "title": "Thông tin người dùng", - "description": "Ghi đè biến môi trường cho từng người dùng của {{name}}", + "title": "Ghi đè người dùng nâng cao", + "description": "Ghi đè tùy chọn theo người dùng cho {{name}}. Dùng Credential theo agent cho thiết lập git PAT hoặc SSH thông thường.", "userId": "ID người dùng", "userIdPlaceholder": "user-id hoặc email", "env": "Biến môi trường", @@ -89,7 +107,7 @@ "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": "Chỉ dùng cho ghi đè cá nhân. Người dùng chat (Telegram, Discord...) cần được gộp vào tenant user qua trang Contacts trước.", "credentialType": "Loại credential", "credentialTypeEnv": "Biến môi trường (cũ)", "credentialTypePAT": "Personal Access Token (PAT)", diff --git a/ui/web/src/i18n/locales/zh/cli-credentials.json b/ui/web/src/i18n/locales/zh/cli-credentials.json index 2709e969..fee0f689 100644 --- a/ui/web/src/i18n/locales/zh/cli-credentials.json +++ b/ui/web/src/i18n/locales/zh/cli-credentials.json @@ -70,9 +70,27 @@ "deleted": "CLI 凭证已删除", "deleteFailed": "删除凭证失败" }, + "agentCredentials": { + "title": "代理凭证", + "description": "配置所选代理运行 {{name}} 时使用的 PAT、SSH 密钥或环境变量凭证。", + "securityHint": "任何可访问所选代理的用户都可以让该代理使用此凭证。", + "agent": "代理", + "env": "环境变量", + "add": "添加代理凭证", + "save": "保存", + "edit": "编辑", + "delete": "删除", + "back": "返回", + "empty": "未配置代理凭证", + "saved": "代理凭证已保存", + "saveFailed": "保存代理凭证失败", + "deleted": "代理凭证已删除", + "deleteFailed": "删除代理凭证失败", + "envRequired": "至少需要一个环境变量" + }, "userCredentials": { - "title": "用户凭证", - "description": "{{name}} 的每用户环境变量覆盖", + "title": "高级用户覆盖", + "description": "{{name}} 的可选每用户覆盖。常规 git PAT 或 SSH 设置请使用代理凭证。", "userId": "用户 ID", "userIdPlaceholder": "用户 ID 或邮箱", "env": "环境变量", @@ -89,7 +107,7 @@ "deleted": "用户凭证已删除", "deleteFailed": "删除用户凭证失败", "envRequired": "至少需要一个环境变量", - "mergeHint": "聊天用户(Telegram、Discord 等)需要先通过联系人页面合并为租户用户,才能设置独立凭证。", + "mergeHint": "仅用于个人覆盖。聊天用户(Telegram、Discord 等)需要先通过联系人页面合并为租户用户。", "credentialType": "凭证类型", "credentialTypeEnv": "环境变量(旧)", "credentialTypePAT": "个人访问令牌 (PAT)", diff --git a/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-dialog-helpers.test.ts b/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-dialog-helpers.test.ts new file mode 100644 index 00000000..22a4b0b2 --- /dev/null +++ b/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-dialog-helpers.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentCredentialPayload } from "../cli-agent-credentials-dialog-helpers"; + +describe("agent credential payload builder", () => { + it("builds a PAT payload for git agent credentials", () => { + const result = buildAgentCredentialPayload({ + isGit: true, + type: "pat", + hostScope: " github.com ", + token: "ghp_test", + privateKey: "", + hasExistingSecret: false, + envEntries: [], + isNewEntry: true, + }); + + expect(result).toEqual({ + kind: "typed", + payload: { + credential_type: "pat", + host_scope: "github.com", + blob: { token: "ghp_test" }, + }, + }); + }); + + it("keeps an existing typed secret when edit form has no replacement secret", () => { + const result = buildAgentCredentialPayload({ + isGit: true, + type: "ssh_key", + hostScope: "git.example.com:2222", + token: "", + privateKey: "", + hasExistingSecret: true, + envEntries: [], + isNewEntry: false, + }); + + expect(result).toEqual({ kind: "no_change" }); + }); + + it("requires env vars for a new env credential", () => { + const result = buildAgentCredentialPayload({ + isGit: false, + type: "env", + hostScope: "", + token: "", + privateKey: "", + hasExistingSecret: false, + envEntries: [], + isNewEntry: true, + }); + + expect(result).toEqual({ kind: "error", errorKey: "env_required" }); + }); +}); diff --git a/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-wiring.test.ts b/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-wiring.test.ts new file mode 100644 index 00000000..00301b9d --- /dev/null +++ b/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-wiring.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function source(path: string): string { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +describe("CLI agent credential UI wiring", () => { + it("exposes Agent Credentials as a distinct table action before advanced user overrides", () => { + const table = source("src/pages/cli-credentials/cli-credentials-table.tsx"); + + expect(table).toContain("onAgentCreds"); + expect(table).toContain("agentCredentials.title"); + expect(table.indexOf("onAgentCreds(item)")).toBeLessThan(table.indexOf("onUserCreds(item)")); + }); + + it("mounts the Agent Credentials dialog from the panel", () => { + const panel = source("src/pages/cli-credentials/cli-credentials-panel.tsx"); + + expect(panel).toContain("cli-agent-credentials-dialog"); + expect(panel).toContain("CLIAgentCredentialsDialog"); + expect(panel).toContain("agentCredsTarget"); + }); +}); diff --git a/ui/web/src/pages/cli-credentials/__tests__/use-cli-credentials.test.ts b/ui/web/src/pages/cli-credentials/__tests__/use-cli-credentials.test.ts index ea3349cd..ab63c93b 100644 --- a/ui/web/src/pages/cli-credentials/__tests__/use-cli-credentials.test.ts +++ b/ui/web/src/pages/cli-credentials/__tests__/use-cli-credentials.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { normalizeCliPreset, normalizeCliPresets } from "../hooks/use-cli-credentials"; describe("cli credential preset normalization", () => { @@ -30,4 +32,15 @@ describe("cli credential preset normalization", () => { expect(normalizeCliPresets(null)).toEqual({}); expect(normalizeCliPresets(undefined)).toEqual({}); }); + + it("keeps agent credential API calls on the agent-credentials endpoint family", () => { + const source = readFileSync( + resolve(process.cwd(), "src/pages/cli-credentials/hooks/use-cli-agent-credentials.ts"), + "utf8", + ); + + expect(source).toContain("/v1/cli-credentials/${binaryId}/agent-credentials"); + expect(source).toContain("/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}"); + expect(source).toContain("useCliAgentCredentials"); + }); }); diff --git a/ui/web/src/pages/cli-credentials/cli-agent-credential-form.tsx b/ui/web/src/pages/cli-credentials/cli-agent-credential-form.tsx new file mode 100644 index 00000000..9d9d957c --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-agent-credential-form.tsx @@ -0,0 +1,84 @@ +import { useTranslation } from "react-i18next"; +import type { Dispatch, SetStateAction } from "react"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { CliCredentialEnvVarsSection, type ManualEnvEntry } from "./cli-credential-env-vars-section"; +import { CliCredentialGitFields, type GitCredentialType } from "./cli-credential-git-fields"; +import type { AgentData } from "@/types/agent"; +import type { SecureCLIBinary } from "./hooks/use-cli-credentials"; + +interface Props { + binary: SecureCLIBinary; + agents: AgentData[]; + agentId: string; + setAgentId: (v: string) => void; + editing: boolean; + envEntries: ManualEnvEntry[]; + setEnvEntries: Dispatch>; + gitType: GitCredentialType; + setGitType: (v: GitCredentialType) => void; + gitHostScope: string; + setGitHostScope: (v: string) => void; + gitToken: string; + setGitToken: (v: string) => void; + gitPrivateKey: string; + setGitPrivateKey: (v: string) => void; + gitErrorKey?: string; + gitHasExistingSecret: boolean; +} + +export function CliAgentCredentialForm(props: Props) { + const { t } = useTranslation("cli-credentials"); + const isGit = props.binary.adapter_name === "git"; + + return ( +
+
+ + +
+ + {isGit ? ( + + ) : null} + + {(!isGit || props.gitType === "env") ? ( +
+ + undefined} + manualEnvEntries={props.envEntries} + setManualEnvEntries={props.setEnvEntries} + /> +
+ ) : null} +
+ ); +} diff --git a/ui/web/src/pages/cli-credentials/cli-agent-credential-list.tsx b/ui/web/src/pages/cli-credentials/cli-agent-credential-list.tsx new file mode 100644 index 00000000..564f10d9 --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-agent-credential-list.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; +import { Loader2, Pencil, Trash2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { CLIAgentCredential } from "./hooks/use-cli-agent-credentials"; + +interface Props { + entries: CLIAgentCredential[]; + agentNameMap: Map; + deleting: string | null; + onEdit: (entry: CLIAgentCredential) => void; + onDelete: (entry: CLIAgentCredential) => void; +} + +export function CliAgentCredentialList({ entries, agentNameMap, deleting, onEdit, onDelete }: Props) { + const { t } = useTranslation("cli-credentials"); + + const credentialLabel = (entry: CLIAgentCredential) => { + if (entry.credential_type === "pat") return t("userCredentials.credentialTypePAT"); + if (entry.credential_type === "ssh_key") return t("userCredentials.credentialTypeSSH"); + return entry.has_secret ? "env" : t("userCredentials.noSecret"); + }; + + return ( +
+ {entries.map((entry) => ( +
+
+
+ + {entry.name || agentNameMap.get(entry.agent_id) || entry.agent_key || entry.agent_id} + + {credentialLabel(entry)} +
+ {entry.host_scope ? ( +

{entry.host_scope}

+ ) : null} +
+
+ + +
+
+ ))} +
+ ); +} diff --git a/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog-helpers.ts b/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog-helpers.ts new file mode 100644 index 00000000..07fa9400 --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog-helpers.ts @@ -0,0 +1,53 @@ +import type { CLIEnvEntryResponse, CLIEnvPayload } from "@/types/cli-credential"; +import type { ManualEnvEntry } from "./cli-credential-env-vars-section"; +import type { GitCredentialType } from "./cli-credential-git-fields"; + +export function entriesFromEnv(env: Record | 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", + })); +} + +export 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 type AgentCredentialPayloadResult = + | { kind: "typed"; payload: { credential_type: "pat" | "ssh_key"; host_scope: string; blob: Record } } + | { kind: "env"; payload: { env: CLIEnvPayload } } + | { kind: "no_change" } + | { kind: "error"; errorKey: string }; + +export function buildAgentCredentialPayload(input: { + isGit: boolean; + type: GitCredentialType; + hostScope: string; + token: string; + privateKey: string; + hasExistingSecret: boolean; + envEntries: ManualEnvEntry[]; + isNewEntry: boolean; +}): AgentCredentialPayloadResult { + const { isGit, type, hostScope, token, privateKey, hasExistingSecret, envEntries, isNewEntry } = input; + if (isGit && type !== "env") { + const scope = hostScope.trim(); + if (!scope) return { kind: "error", errorKey: "git.cred_host_scope_required" }; + if (type === "pat") { + if (!token) return hasExistingSecret ? { kind: "no_change" } : { kind: "error", errorKey: "git.cred_blob_missing_token" }; + return { kind: "typed", payload: { credential_type: "pat", host_scope: scope, blob: { token } } }; + } + if (!privateKey.trim()) return hasExistingSecret ? { kind: "no_change" } : { kind: "error", errorKey: "git.cred_blob_missing_key" }; + return { kind: "typed", payload: { credential_type: "ssh_key", host_scope: scope, blob: { key: privateKey } } }; + } + const env = envPayloadFromEntries(envEntries); + if (isNewEntry && Object.keys(env).length === 0) return { kind: "error", errorKey: "env_required" }; + return { kind: "env", payload: { env } }; +} diff --git a/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog.tsx b/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog.tsx new file mode 100644 index 00000000..ddcc29ea --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog.tsx @@ -0,0 +1,199 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Bot, Loader2, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useAgents } from "@/pages/agents/hooks/use-agents"; +import { toast } from "@/stores/use-toast-store"; +import { type ManualEnvEntry } from "./cli-credential-env-vars-section"; +import { CliAgentCredentialForm } from "./cli-agent-credential-form"; +import { CliAgentCredentialList } from "./cli-agent-credential-list"; +import { + buildAgentCredentialPayload, + entriesFromEnv, +} from "./cli-agent-credentials-dialog-helpers"; +import { type GitCredentialType } from "./cli-credential-git-fields"; +import { useCliAgentCredentials, type CLIAgentCredential } from "./hooks/use-cli-agent-credentials"; +import type { SecureCLIBinary } from "./hooks/use-cli-credentials"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + binary: SecureCLIBinary; +} + +type ViewState = "list" | "form"; + +export function CLIAgentCredentialsDialog({ open, onOpenChange, binary }: Props) { + const { t } = useTranslation("cli-credentials"); + const { t: tc } = useTranslation("common"); + const { agents } = useAgents(); + const { agentCredentials, loading, getCredential, setCredential, deleteCredential } = useCliAgentCredentials(binary.id); + const isGit = binary.adapter_name === "git"; + + const [view, setView] = useState("list"); + const [editEntry, setEditEntry] = useState(null); + const [agentId, setAgentId] = useState(""); + const [envEntries, setEnvEntries] = useState([]); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(null); + const [gitType, setGitType] = useState("pat"); + const [gitHostScope, setGitHostScope] = useState("github.com"); + const [gitToken, setGitToken] = useState(""); + const [gitPrivateKey, setGitPrivateKey] = useState(""); + const [gitErrorKey, setGitErrorKey] = useState(); + const [gitHasExistingSecret, setGitHasExistingSecret] = useState(false); + + const agentNameMap = useMemo(() => { + const map = new Map(); + for (const a of agents) map.set(a.id, a.display_name || a.agent_key); + return map; + }, [agents]); + + const clearForm = () => { + setEditEntry(null); + setAgentId(""); + setEnvEntries([]); + setGitType(isGit ? "pat" : "env"); + setGitHostScope(isGit ? "github.com" : ""); + setGitToken(""); + setGitPrivateKey(""); + setGitErrorKey(undefined); + setGitHasExistingSecret(false); + }; + + useEffect(() => { + if (!open) { + clearForm(); + return; + } + setView("list"); + clearForm(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, binary.id]); + + const openAdd = () => { + clearForm(); + setView("form"); + }; + + const openEdit = async (entry: CLIAgentCredential) => { + setEditEntry(entry); + setAgentId(entry.agent_id); + setGitErrorKey(undefined); + setView("form"); + try { + const res = await getCredential(entry.agent_id); + setEnvEntries(entriesFromEnv(res.env)); + if (isGit) { + const nextType = (res.credential_type ?? "env") as GitCredentialType; + setGitType(nextType === "pat" || nextType === "ssh_key" ? nextType : "env"); + setGitHostScope(res.host_scope ?? "github.com"); + setGitHasExistingSecret(!!res.has_secret); + setGitToken(""); + setGitPrivateKey(""); + } + } catch { + setEnvEntries([]); + } + }; + + const handleSave = async () => { + if (!agentId) return; + setGitErrorKey(undefined); + const result = buildAgentCredentialPayload({ + isGit, + type: gitType, + hostScope: gitHostScope, + token: gitToken, + privateKey: gitPrivateKey, + hasExistingSecret: gitHasExistingSecret, + envEntries, + isNewEntry: editEntry === null, + }); + if (result.kind === "error") { + if (result.errorKey === "env_required") toast.error(t("agentCredentials.envRequired")); + else setGitErrorKey(result.errorKey); + return; + } + if (result.kind === "no_change") { + toast.success(t("agentCredentials.saved")); + setView("list"); + return; + } + setSaving(true); + try { + await setCredential(agentId, result.payload); + clearForm(); + setView("list"); + } catch (err) { + const code = (err as { code?: string })?.code; + if (code?.startsWith("git.cred_")) setGitErrorKey(code); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (entry: CLIAgentCredential) => { + setDeleting(entry.agent_id); + try { + await deleteCredential(entry.agent_id); + } finally { + setDeleting(null); + } + }; + + return ( + + + + {t("agentCredentials.title")} + {t("agentCredentials.description", { name: binary.binary_name })} + +
+ {view === "list" ? ( + <> +

{t("agentCredentials.securityHint")}

+ {loading ?

{tc("loading")}

: null} + {agentCredentials.length === 0 ?

{t("agentCredentials.empty")}

: null} + + + ) : ( + + )} +
+ + {view === "list" ? ( + <> + ) : ( + <> + )} + +
+
+ ); +} diff --git a/ui/web/src/pages/cli-credentials/cli-credentials-panel.tsx b/ui/web/src/pages/cli-credentials/cli-credentials-panel.tsx index a6e745bb..eca1ce07 100644 --- a/ui/web/src/pages/cli-credentials/cli-credentials-panel.tsx +++ b/ui/web/src/pages/cli-credentials/cli-credentials-panel.tsx @@ -24,6 +24,9 @@ const CliCredentialFormDialog = lazy(() => const CLIUserCredentialsDialog = lazy(() => import("./cli-user-credentials-dialog").then((m) => ({ default: m.CLIUserCredentialsDialog })) ); +const CLIAgentCredentialsDialog = lazy(() => + import("./cli-agent-credentials-dialog").then((m) => ({ default: m.CLIAgentCredentialsDialog })) +); export function CliCredentialsPanel() { const { t } = useTranslation("cli-credentials"); @@ -34,6 +37,7 @@ export function CliCredentialsPanel() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); const [userCredsTarget, setUserCredsTarget] = useState(null); + const [agentCredsTarget, setAgentCredsTarget] = useState(null); const [grantsTarget, setGrantsTarget] = useState(null); const { items, loading, refresh, createCredential, updateCredential, deleteCredential } = @@ -88,6 +92,7 @@ export function CliCredentialsPanel() { onEdit={openEdit} onDelete={setDeleteTarget} onUserCreds={setUserCredsTarget} + onAgentCreds={setAgentCredsTarget} onGrants={setGrantsTarget} /> {/* Finding #12: surface LIMIT 20 truncation so admins know there are more entries. */} @@ -130,6 +135,16 @@ export function CliCredentialsPanel() { )} + {agentCredsTarget && ( + + !open && setAgentCredsTarget(null)} + binary={agentCredsTarget} + /> + + )} + {grantsTarget && ( void; onDelete: (item: SecureCLIBinary) => void; onUserCreds: (item: SecureCLIBinary) => void; + onAgentCreds: (item: SecureCLIBinary) => void; onGrants: (item: SecureCLIBinary) => void; } -export function CliCredentialsTable({ items, onEdit, onDelete, onUserCreds, onGrants }: Props) { +export function CliCredentialsTable({ items, onEdit, onDelete, onUserCreds, onAgentCreds, onGrants }: Props) { const { t } = useTranslation("cli-credentials"); const { t: tc } = useTranslation("common"); @@ -77,6 +78,9 @@ export function CliCredentialsTable({ items, onEdit, onDelete, onUserCreds, onGr {t("grants.addGrant")} + diff --git a/ui/web/src/pages/cli-credentials/hooks/use-cli-agent-credentials.ts b/ui/web/src/pages/cli-credentials/hooks/use-cli-agent-credentials.ts new file mode 100644 index 00000000..a1c941ee --- /dev/null +++ b/ui/web/src/pages/cli-credentials/hooks/use-cli-agent-credentials.ts @@ -0,0 +1,80 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { toast } from "@/stores/use-toast-store"; +import i18n from "@/i18n"; +import type { CLIAgentCredential, CLIAgentCredentialInput } from "@/types/cli-credential"; + +export type { CLIAgentCredential, CLIAgentCredentialInput }; + +/** Hook for managing agent-scoped credentials on a specific CLI binary. */ +export function useCliAgentCredentials(binaryId: string) { + const http = useHttp(); + const queryClient = useQueryClient(); + const queryKey = ["cliCredentials", binaryId, "agentCredentials"] as const; + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: async () => { + const res = await http.get<{ agent_credentials: CLIAgentCredential[] }>( + `/v1/cli-credentials/${binaryId}/agent-credentials`, + ); + return res.agent_credentials ?? []; + }, + enabled: !!binaryId, + }); + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey }), + [queryClient, queryKey], + ); + + const getCredential = useCallback( + (agentId: string) => http.get( + `/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}`, + ), + [http, binaryId], + ); + + const setCredential = useCallback( + async (agentId: string, input: CLIAgentCredentialInput) => { + try { + await http.put(`/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}`, input); + await invalidate(); + toast.success(i18n.t("cli-credentials:agentCredentials.saved")); + } catch (err) { + toast.error( + i18n.t("cli-credentials:agentCredentials.saveFailed"), + err instanceof Error ? err.message : "", + ); + throw err; + } + }, + [http, binaryId, invalidate], + ); + + const deleteCredential = useCallback( + async (agentId: string) => { + try { + await http.delete(`/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}`); + await invalidate(); + toast.success(i18n.t("cli-credentials:agentCredentials.deleted")); + } catch (err) { + toast.error( + i18n.t("cli-credentials:agentCredentials.deleteFailed"), + err instanceof Error ? err.message : "", + ); + throw err; + } + }, + [http, binaryId, invalidate], + ); + + return { + agentCredentials: data ?? [], + loading: isLoading, + getCredential, + setCredential, + deleteCredential, + }; +} diff --git a/ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts b/ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts index e31e7041..43211c97 100644 --- a/ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts +++ b/ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts @@ -3,9 +3,21 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useHttp } from "@/hooks/use-ws"; import { toast } from "@/stores/use-toast-store"; import i18n from "@/i18n"; -import type { SecureCLIBinary, CLICredentialInput, CLIPreset, CLIAgentGrant, CLIAgentGrantInput } from "@/types/cli-credential"; +import type { + SecureCLIBinary, + CLICredentialInput, + CLIPreset, + CLIAgentGrant, + CLIAgentGrantInput, +} from "@/types/cli-credential"; -export type { SecureCLIBinary, CLICredentialInput, CLIPreset, CLIAgentGrant, CLIAgentGrantInput }; +export type { + SecureCLIBinary, + CLICredentialInput, + CLIPreset, + CLIAgentGrant, + CLIAgentGrantInput, +}; const QUERY_KEY = ["cliCredentials"] as const; const PRESETS_KEY = ["cliCredentials", "presets"] as const; diff --git a/ui/web/src/types/cli-credential.ts b/ui/web/src/types/cli-credential.ts index b93f8d77..ce430fec 100644 --- a/ui/web/src/types/cli-credential.ts +++ b/ui/web/src/types/cli-credential.ts @@ -12,6 +12,7 @@ export interface CLIEnvEntryResponse { } export type CLIEnvPayload = Record; +export type CLIGitCredentialType = "env" | "pat" | "ssh_key"; export interface SecureCLIBinary { id: string; @@ -116,6 +117,29 @@ export interface CLIAgentGrantInput { env_vars?: CLIEnvPayload | null; } +export interface CLIAgentCredential { + id: string; + binary_id: string; + agent_id: string; + agent_key?: string; + name?: string; + has_secret: boolean; + env_keys?: string[]; + env?: Record; + credential_type?: CLIGitCredentialType | string | null; + host_scope?: string | null; + created_by?: string; + created_at: string; + updated_at: string; +} + +export interface CLIAgentCredentialInput { + env?: CLIEnvPayload; + credential_type?: Exclude; + host_scope?: string; + blob?: Record; +} + /** Summary of a single grant shown in the table row chips (Phase 4 API field). */ export interface AgentGrantSummary { grant_id: string;