From 156b2dd96c3f09fc7f918d861b7d422beaa46da1 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 4 Apr 2026 13:18:57 +0700 Subject: [PATCH] feat(secure-cli): per-agent grants with setting overrides Replace agent_id column on secure_cli_binaries with is_global flag and new secure_cli_agent_grants table for per-agent access control with optional deny_args, deny_verbose, timeout_seconds, tips overrides. - Migration 000036: create grants table, migrate agent-specific rows, dedup binaries, drop agent_id, add is_global - Store layer: SecureCLIAgentGrantStore interface + PG implementation, LookupByBinary with LEFT JOIN grant merge, ListForAgent - HTTP API: CRUD endpoints at /v1/cli-credentials/{id}/agent-grants - Agent loop: buildCredentialCLIContext uses ListForAgent for scoped system prompt (agents only see authorized CLIs) - Web UI: grants dialog with card list + inline form, is_global toggle replaces agent dropdown, i18n for en/vi/zh --- cmd/gateway.go | 5 +- cmd/gateway_http_handlers.go | 8 +- internal/agent/loop_history.go | 11 +- internal/gateway/server.go | 5 + internal/http/secure_cli.go | 6 +- internal/http/secure_cli_agent_grants.go | 174 ++++++++++ internal/store/pg/factory.go | 5 +- internal/store/pg/secure_cli.go | 297 +++++++++++------- internal/store/pg/secure_cli_agent_grants.go | 200 ++++++++++++ internal/store/secure_cli_store.go | 59 +++- internal/store/sqlitestore/schema.go | 24 +- internal/store/sqlitestore/schema.sql | 29 +- internal/store/stores.go | 5 +- internal/upgrade/version.go | 2 +- .../000036_secure_cli_agent_grants.down.sql | 10 + .../000036_secure_cli_agent_grants.up.sql | 95 ++++++ .../src/i18n/locales/en/cli-credentials.json | 31 +- .../src/i18n/locales/vi/cli-credentials.json | 31 +- .../src/i18n/locales/zh/cli-credentials.json | 31 +- .../cli-credential-form-dialog.tsx | 12 +- .../cli-credential-grant-card.tsx | 76 +++++ .../cli-credential-grant-form.tsx | 132 ++++++++ .../cli-credential-grants-dialog.tsx | 165 ++++++++++ .../cli-credential-scope-fields.tsx | 44 +-- .../cli-credentials/cli-credentials-page.tsx | 19 +- .../hooks/use-cli-credentials.ts | 78 ++++- ui/web/src/schemas/credential.schema.ts | 2 +- ui/web/src/types/cli-credential.ts | 27 +- 28 files changed, 1395 insertions(+), 188 deletions(-) create mode 100644 internal/http/secure_cli_agent_grants.go create mode 100644 internal/store/pg/secure_cli_agent_grants.go create mode 100644 migrations/000036_secure_cli_agent_grants.down.sql create mode 100644 migrations/000036_secure_cli_agent_grants.up.sql create mode 100644 ui/web/src/pages/cli-credentials/cli-credential-grant-card.tsx create mode 100644 ui/web/src/pages/cli-credentials/cli-credential-grant-form.tsx create mode 100644 ui/web/src/pages/cli-credentials/cli-credential-grants-dialog.tsx diff --git a/cmd/gateway.go b/cmd/gateway.go index ff39bb2e..f05b2a03 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -356,7 +356,7 @@ func runGateway() { mcpToolLister = mcpMgr } httpapi.InitGatewayToken(cfg.Gateway.Token) - agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister) + agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister) if providersH != nil { providersH.SetAPIBaseFallback(cfg.Providers.APIBaseForType) } @@ -412,6 +412,9 @@ func runGateway() { if secureCLIH != nil { server.SetSecureCLIHandler(secureCLIH) } + if secureCLIGrantH != nil { + server.SetSecureCLIGrantHandler(secureCLIGrantH) + } // Activity audit log API if pgStores.Activity != nil { diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index fbd523c1..64d93066 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -9,7 +9,7 @@ import ( ) // wireHTTP creates HTTP handlers (agents + skills + traces + MCP + channel instances + providers + builtin tools + pending messages). -func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.MCPUserCredentialsHandler) { +func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.SecureCLIGrantHandler, *httpapi.MCPUserCredentialsHandler) { var agentsH *httpapi.AgentsHandler var skillsH *httpapi.SkillsHandler var tracesH *httpapi.TracesHandler @@ -19,6 +19,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir var builtinToolsH *httpapi.BuiltinToolsHandler var pendingMessagesH *httpapi.PendingMessagesHandler var secureCLIH *httpapi.SecureCLIHandler + var secureCLIGrantH *httpapi.SecureCLIGrantHandler if stores != nil && stores.Agents != nil { var summoner *httpapi.AgentSummoner @@ -91,6 +92,9 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if stores != nil && stores.SecureCLI != nil { secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, msgBus) } + if stores != nil && stores.SecureCLIGrants != nil { + secureCLIGrantH = httpapi.NewSecureCLIGrantHandler(stores.SecureCLIGrants, msgBus) + } - return agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH + return agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH } diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index a03a9df0..90e39cf3 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -66,12 +66,19 @@ func (l *Loop) filteredToolNamesForChannel(channelType string) []string { } // buildCredentialCLIContext generates the TOOLS.md supplement for credentialed CLIs. -// Returns empty string if no secure CLI store is configured or no enabled CLIs. +// Uses agent-scoped list when agent UUID is available: returns only global CLIs +// plus explicitly granted CLIs, with grant overrides merged. func (l *Loop) buildCredentialCLIContext(ctx context.Context) string { if l.secureCLIStore == nil { return "" } - creds, err := l.secureCLIStore.ListEnabled(ctx) + var creds []store.SecureCLIBinary + var err error + if l.agentUUID != uuid.Nil { + creds, err = l.secureCLIStore.ListForAgent(ctx, l.agentUUID) + } else { + creds, err = l.secureCLIStore.ListEnabled(ctx) + } if err != nil || len(creds) == 0 { return "" } diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 3a6adbac..e2267247 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -431,6 +431,11 @@ func (s *Server) SetSecureCLIHandler(h *httpapi.SecureCLIHandler) { s.handlers = append(s.handlers, h) } +// SetSecureCLIGrantHandler sets the per-agent secure CLI grant handler. +func (s *Server) SetSecureCLIGrantHandler(h *httpapi.SecureCLIGrantHandler) { + s.handlers = append(s.handlers, h) +} + // SetPackagesHandler sets the runtime package management handler. func (s *Server) SetPackagesHandler(h *httpapi.PackagesHandler) { s.handlers = append(s.handlers, h) diff --git a/internal/http/secure_cli.go b/internal/http/secure_cli.go index 672c1507..0eac1b7f 100644 --- a/internal/http/secure_cli.go +++ b/internal/http/secure_cli.go @@ -160,7 +160,7 @@ type secureCLICreateRequest struct { DenyVerbose json.RawMessage `json:"deny_verbose,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` Tips string `json:"tips,omitempty"` - AgentID *uuid.UUID `json:"agent_id,omitempty"` + IsGlobal *bool `json:"is_global,omitempty"` Enabled bool `json:"enabled"` } @@ -224,7 +224,7 @@ func (h *SecureCLIHandler) handleCreate(w http.ResponseWriter, r *http.Request) DenyVerbose: req.DenyVerbose, TimeoutSeconds: req.TimeoutSeconds, Tips: req.Tips, - AgentID: req.AgentID, + IsGlobal: req.IsGlobal == nil || *req.IsGlobal, // default true Enabled: req.Enabled, CreatedBy: store.UserIDFromContext(r.Context()), } @@ -282,7 +282,7 @@ func (h *SecureCLIHandler) handleUpdate(w http.ResponseWriter, r *http.Request) allowed := map[string]bool{ "binary_name": true, "binary_path": true, "description": true, "env": true, "deny_args": true, "deny_verbose": true, - "timeout_seconds": true, "tips": true, "agent_id": true, "enabled": true, + "timeout_seconds": true, "tips": true, "is_global": true, "enabled": true, } for k := range updates { if !allowed[k] { diff --git a/internal/http/secure_cli_agent_grants.go b/internal/http/secure_cli_agent_grants.go new file mode 100644 index 00000000..fca73a8e --- /dev/null +++ b/internal/http/secure_cli_agent_grants.go @@ -0,0 +1,174 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// SecureCLIGrantHandler handles CRUD for per-agent secure CLI grants. +type SecureCLIGrantHandler struct { + grants store.SecureCLIAgentGrantStore + msgBus *bus.MessageBus +} + +func NewSecureCLIGrantHandler(gs store.SecureCLIAgentGrantStore, msgBus *bus.MessageBus) *SecureCLIGrantHandler { + return &SecureCLIGrantHandler{grants: gs, msgBus: msgBus} +} + +// RegisterRoutes registers agent grant routes nested under cli-credentials. +func (h *SecureCLIGrantHandler) RegisterRoutes(mux *http.ServeMux) { + auth := func(next http.HandlerFunc) http.HandlerFunc { + return requireAuth(permissions.RoleAdmin, next) + } + mux.HandleFunc("GET /v1/cli-credentials/{id}/agent-grants", auth(h.handleList)) + mux.HandleFunc("POST /v1/cli-credentials/{id}/agent-grants", auth(h.handleCreate)) + mux.HandleFunc("GET /v1/cli-credentials/{id}/agent-grants/{grantId}", auth(h.handleGet)) + mux.HandleFunc("PUT /v1/cli-credentials/{id}/agent-grants/{grantId}", auth(h.handleUpdate)) + mux.HandleFunc("DELETE /v1/cli-credentials/{id}/agent-grants/{grantId}", auth(h.handleDelete)) +} + +func (h *SecureCLIGrantHandler) handleList(w http.ResponseWriter, r *http.Request) { + 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 + } + grants, err := h.grants.ListByBinary(r.Context(), binaryID) + if err != nil { + slog.Error("secure_cli_grants.list", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "grants")}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"grants": grants}) +} + +type grantCreateRequest struct { + AgentID uuid.UUID `json:"agent_id"` + DenyArgs *json.RawMessage `json:"deny_args,omitempty"` + DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty"` + TimeoutSeconds *int `json:"timeout_seconds,omitempty"` + Tips *string `json:"tips,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +func (h *SecureCLIGrantHandler) handleCreate(w http.ResponseWriter, r *http.Request) { + 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 + } + + var req grantCreateRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + if req.AgentID == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "agent_id")}) + return + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + + g := &store.SecureCLIAgentGrant{ + BinaryID: binaryID, + AgentID: req.AgentID, + DenyArgs: req.DenyArgs, + DenyVerbose: req.DenyVerbose, + TimeoutSeconds: req.TimeoutSeconds, + Tips: req.Tips, + Enabled: enabled, + } + if err := h.grants.Create(r.Context(), g); err != nil { + slog.Error("secure_cli_grants.create", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + h.emitCacheInvalidate(binaryID.String()) + writeJSON(w, http.StatusCreated, g) +} + +func (h *SecureCLIGrantHandler) handleGet(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + grantID, err := uuid.Parse(r.PathValue("grantId")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "grant")}) + return + } + g, err := h.grants.Get(r.Context(), grantID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "grant", grantID.String())}) + return + } + writeJSON(w, http.StatusOK, g) +} + +func (h *SecureCLIGrantHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + grantID, err := uuid.Parse(r.PathValue("grantId")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "grant")}) + return + } + + var updates map[string]any + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + updates["updated_at"] = time.Now() + if err := h.grants.Update(r.Context(), grantID, updates); err != nil { + slog.Error("secure_cli_grants.update", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + binaryID := r.PathValue("id") + h.emitCacheInvalidate(binaryID) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *SecureCLIGrantHandler) handleDelete(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + grantID, err := uuid.Parse(r.PathValue("grantId")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "grant")}) + return + } + if err := h.grants.Delete(r.Context(), grantID); err != nil { + slog.Error("secure_cli_grants.delete", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + binaryID := r.PathValue("id") + h.emitCacheInvalidate(binaryID) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *SecureCLIGrantHandler) emitCacheInvalidate(key string) { + if h.msgBus == nil { + return + } + h.msgBus.Broadcast(bus.Event{ + Name: protocol.EventCacheInvalidate, + Payload: map[string]any{"scope": "secure_cli", "key": key}, + }) +} diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index 0e86cb01..56d4f0ec 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -42,8 +42,9 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { Contacts: NewPGContactStore(db), Activity: NewPGActivityStore(db), Snapshots: NewPGSnapshotStore(db), - SecureCLI: NewPGSecureCLIStore(db, cfg.EncryptionKey), - APIKeys: NewPGAPIKeyStore(db), + SecureCLI: NewPGSecureCLIStore(db, cfg.EncryptionKey), + SecureCLIGrants: NewPGSecureCLIAgentGrantStore(db), + APIKeys: NewPGAPIKeyStore(db), Heartbeats: NewPGHeartbeatStore(db), ConfigPermissions: NewPGConfigPermissionStore(db), Tenants: NewPGTenantStore(db), diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go index 96c882d9..73642c9e 100644 --- a/internal/store/pg/secure_cli.go +++ b/internal/store/pg/secure_cli.go @@ -3,6 +3,7 @@ package pg import ( "context" "database/sql" + "encoding/json" "fmt" "log/slog" "time" @@ -24,12 +25,12 @@ func NewPGSecureCLIStore(db *sql.DB, encryptionKey string) *PGSecureCLIStore { } const secureCLISelectCols = `id, binary_name, binary_path, description, encrypted_env, - deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at` + deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by, created_at, updated_at` -// secureCLISelectColsAliased is the same as secureCLISelectCols but prefixed with table alias "b." +// secureCLISelectColsAliased is prefixed with table alias "b." // Required for LookupByBinary which uses LEFT JOIN (ambiguous column names without prefix). const secureCLISelectColsAliased = `b.id, b.binary_name, b.binary_path, b.description, b.encrypted_env, - b.deny_args, b.deny_verbose, b.timeout_seconds, b.tips, b.agent_id, b.enabled, b.created_by, b.created_at, b.updated_at` + b.deny_args, b.deny_verbose, b.timeout_seconds, b.tips, b.is_global, b.enabled, b.created_by, b.created_at, b.updated_at` func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary) error { if err := store.ValidateUserID(b.CreatedBy); err != nil { @@ -62,13 +63,13 @@ func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary) _, err := s.db.ExecContext(ctx, `INSERT INTO secure_cli_binaries (id, binary_name, binary_path, description, encrypted_env, - deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at, tenant_id) + deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by, created_at, updated_at, tenant_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, b.ID, b.BinaryName, nilStr(derefStr(b.BinaryPath)), b.Description, envBytes, jsonOrEmptyArray(b.DenyArgs), jsonOrEmptyArray(b.DenyVerbose), b.TimeoutSeconds, b.Tips, - nilUUID(b.AgentID), b.Enabled, + b.IsGlobal, b.Enabled, b.CreatedBy, now, now, tenantID, ) return err @@ -92,14 +93,13 @@ func (s *PGSecureCLIStore) Get(ctx context.Context, id uuid.UUID) (*store.Secure func (s *PGSecureCLIStore) scanRow(row *sql.Row) (*store.SecureCLIBinary, error) { var b store.SecureCLIBinary var binaryPath *string - var agentID *uuid.UUID var denyArgs, denyVerbose *[]byte var env []byte err := row.Scan( &b.ID, &b.BinaryName, &binaryPath, &b.Description, &env, &denyArgs, &denyVerbose, - &b.TimeoutSeconds, &b.Tips, &agentID, + &b.TimeoutSeconds, &b.Tips, &b.IsGlobal, &b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt, ) if err != nil { @@ -107,7 +107,6 @@ func (s *PGSecureCLIStore) scanRow(row *sql.Row) (*store.SecureCLIBinary, error) } b.BinaryPath = binaryPath - b.AgentID = agentID if denyArgs != nil { b.DenyArgs = *denyArgs } @@ -136,21 +135,19 @@ func (s *PGSecureCLIStore) scanRows(rows *sql.Rows) ([]store.SecureCLIBinary, er for rows.Next() { var b store.SecureCLIBinary var binaryPath *string - var agentID *uuid.UUID var denyArgs, denyVerbose *[]byte var env []byte if err := rows.Scan( &b.ID, &b.BinaryName, &binaryPath, &b.Description, &env, &denyArgs, &denyVerbose, - &b.TimeoutSeconds, &b.Tips, &agentID, + &b.TimeoutSeconds, &b.Tips, &b.IsGlobal, &b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt, ); err != nil { continue } b.BinaryPath = binaryPath - b.AgentID = agentID if denyArgs != nil { b.DenyArgs = *denyArgs } @@ -171,16 +168,14 @@ func (s *PGSecureCLIStore) scanRows(rows *sql.Rows) ([]store.SecureCLIBinary, er } // secureCLIAllowedFields is the allowlist of columns that can be updated via execMapUpdate. -// Defense-in-depth: prevents column name injection even if caller skips validation. var secureCLIAllowedFields = map[string]bool{ "binary_name": true, "binary_path": true, "description": true, "encrypted_env": true, "deny_args": true, "deny_verbose": true, - "timeout_seconds": true, "tips": true, "agent_id": true, "enabled": true, + "timeout_seconds": true, "tips": true, "is_global": true, "enabled": true, "updated_at": true, } func (s *PGSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error { - // Filter unknown fields to prevent column name injection for k := range updates { if !secureCLIAllowedFields[k] { delete(updates, k) @@ -232,7 +227,7 @@ func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, e query += ` WHERE tenant_id = $1` qArgs = append(qArgs, tenantID) } - query += ` ORDER BY binary_name, agent_id NULLS LAST` + query += ` ORDER BY binary_name` rows, err := s.db.QueryContext(ctx, query, qArgs...) if err != nil { return nil, err @@ -240,34 +235,9 @@ func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, e return s.scanRows(rows) } -func (s *PGSecureCLIStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) { - if store.IsCrossTenant(ctx) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries - WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true - ORDER BY binary_name, agent_id NULLS LAST`, agentID) - if err != nil { - return nil, err - } - return s.scanRows(rows) - } - tenantID := store.TenantIDFromContext(ctx) - if tenantID == uuid.Nil { - return nil, nil - } - rows, err := s.db.QueryContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries - WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true AND tenant_id = $2 - ORDER BY binary_name, agent_id NULLS LAST`, agentID, tenantID) - if err != nil { - return nil, err - } - return s.scanRows(rows) -} - -// LookupByBinary finds the best credential config for a binary name. -// Agent-specific config takes priority over global (agent_id IS NULL). -// If userID is non-empty, also fetches per-user env overrides via LEFT JOIN (zero extra queries). +// LookupByBinary finds the credential config for a binary name. +// Checks agent grant authorization and merges overrides if agentID is provided. +// Also fetches per-user env overrides via LEFT JOIN when userID is non-empty. func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID, userID string) (*store.SecureCLIBinary, error) { tid := store.TenantIDFromContext(ctx) isCross := store.IsCrossTenant(ctx) @@ -275,104 +245,107 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string return nil, nil } - // Build query with optional LEFT JOIN for per-user credentials. - // Use aliased columns (b.) to avoid ambiguous column reference with JOIN. + // Build SELECT columns with optional LEFT JOINs for grant overrides and user env selectCols := secureCLISelectColsAliased - joinClause := "" + grantCols := ", g.deny_args AS grant_deny_args, g.deny_verbose AS grant_deny_verbose, g.timeout_seconds AS grant_timeout, g.tips AS grant_tips, g.enabled AS grant_enabled, g.id AS grant_id" + selectCols += grantCols + + var joinClause string if userID != "" { selectCols += ", uc.encrypted_env AS user_env" - joinClause = " LEFT JOIN secure_cli_user_credentials uc ON uc.binary_id = b.id AND uc.user_id = $%d AND uc.tenant_id = $%d" } else { selectCols += ", NULL AS user_env" } var args []any - var query string + argIdx := 1 + // Base query + query := `SELECT ` + selectCols + ` FROM secure_cli_binaries b` + + // LEFT JOIN agent grant if agentID != nil { - if userID != "" { - if isCross { - args = []any{binaryName, *agentID, userID, tid} - query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` + - fmt.Sprintf(joinClause, 3, 4) + - ` WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true - ORDER BY b.agent_id NULLS LAST LIMIT 1` - } else { - args = []any{binaryName, *agentID, tid, userID, tid} - query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` + - fmt.Sprintf(joinClause, 4, 5) + - ` WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true AND b.tenant_id = $3 - ORDER BY b.agent_id NULLS LAST LIMIT 1` - } - } else { - if isCross { - args = []any{binaryName, *agentID} - query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b - WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true - ORDER BY b.agent_id NULLS LAST LIMIT 1` - } else { - args = []any{binaryName, *agentID, tid} - query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b - WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true AND b.tenant_id = $3 - ORDER BY b.agent_id NULLS LAST LIMIT 1` - } - } + query += fmt.Sprintf(` LEFT JOIN secure_cli_agent_grants g ON g.binary_id = b.id AND g.agent_id = $%d`, argIdx) + args = append(args, *agentID) + argIdx++ } else { - if userID != "" { - if isCross { - args = []any{binaryName, userID, tid} - query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` + - fmt.Sprintf(joinClause, 2, 3) + - ` WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true LIMIT 1` - } else { - args = []any{binaryName, tid, userID, tid} - query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` + - fmt.Sprintf(joinClause, 3, 4) + - ` WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true AND b.tenant_id = $2 LIMIT 1` - } - } else { - if isCross { - args = []any{binaryName} - query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b - WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true LIMIT 1` - } else { - args = []any{binaryName, tid} - query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b - WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true AND b.tenant_id = $2 LIMIT 1` - } - } + query += ` LEFT JOIN secure_cli_agent_grants g ON FALSE` // never match } + // LEFT JOIN user credentials + if userID != "" { + joinClause = fmt.Sprintf(` LEFT JOIN secure_cli_user_credentials uc ON uc.binary_id = b.id AND uc.user_id = $%d`, argIdx) + args = append(args, userID) + argIdx++ + if !isCross { + joinClause += fmt.Sprintf(` AND uc.tenant_id = $%d`, argIdx) + args = append(args, tid) + argIdx++ + } + query += joinClause + } + + // WHERE clause + query += fmt.Sprintf(` WHERE b.binary_name = $%d AND b.enabled = true`, argIdx) + args = append(args, binaryName) + argIdx++ + + if !isCross { + query += fmt.Sprintf(` AND b.tenant_id = $%d`, argIdx) + args = append(args, tid) + argIdx++ + } + + // Authorization: global (no grant needed OR has enabled grant) OR non-global (must have enabled grant) + if agentID != nil { + query += ` AND ( + (b.is_global = true AND (g.id IS NULL OR g.enabled = true)) + OR + (b.is_global = false AND g.id IS NOT NULL AND g.enabled = true) + )` + } else { + // No agent context — only return global binaries + query += ` AND b.is_global = true` + } + + query += ` LIMIT 1` + row := s.db.QueryRowContext(ctx, query, args...) - b, err := s.scanRowWithUserEnv(row) - if err == sql.ErrNoRows { - return nil, nil - } - return b, err + return s.scanRowWithGrantAndUserEnv(row) } -// scanRowWithUserEnv scans a row that includes the extra user_env column from LEFT JOIN. -func (s *PGSecureCLIStore) scanRowWithUserEnv(row *sql.Row) (*store.SecureCLIBinary, error) { +// scanRowWithGrantAndUserEnv scans a row that includes grant override columns and user_env. +func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.SecureCLIBinary, error) { var b store.SecureCLIBinary var binaryPath *string - var agentID *uuid.UUID var denyArgs, denyVerbose *[]byte var env []byte + // Grant override columns (nullable) + var grantDenyArgs, grantDenyVerbose *[]byte + var grantTimeout *int + var grantTips *string + var grantEnabled *bool + var grantID *uuid.UUID var userEnv []byte err := row.Scan( &b.ID, &b.BinaryName, &binaryPath, &b.Description, &env, &denyArgs, &denyVerbose, - &b.TimeoutSeconds, &b.Tips, &agentID, + &b.TimeoutSeconds, &b.Tips, &b.IsGlobal, &b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt, + // Grant columns + &grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantEnabled, &grantID, + // User env &userEnv, ) if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } return nil, err } b.BinaryPath = binaryPath - b.AgentID = agentID if denyArgs != nil { b.DenyArgs = *denyArgs } @@ -389,6 +362,22 @@ func (s *PGSecureCLIStore) scanRowWithUserEnv(row *sql.Row) (*store.SecureCLIBin b.EncryptedEnv = env } + // Apply grant overrides (if grant exists) + if grantID != nil { + grant := &store.SecureCLIAgentGrant{} + if grantDenyArgs != nil { + raw := json.RawMessage(*grantDenyArgs) + grant.DenyArgs = &raw + } + if grantDenyVerbose != nil { + raw := json.RawMessage(*grantDenyVerbose) + grant.DenyVerbose = &raw + } + grant.TimeoutSeconds = grantTimeout + grant.Tips = grantTips + b.MergeGrantOverrides(grant) + } + // Decrypt per-user env if len(userEnv) > 0 && s.encKey != "" { if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil { @@ -417,3 +406,95 @@ func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBi } return s.scanRows(rows) } + +// ListForAgent returns all CLIs accessible by an agent (global + granted), +// with grant overrides merged into the returned configs. +func (s *PGSecureCLIStore) ListForAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) { + tid := store.TenantIDFromContext(ctx) + isCross := store.IsCrossTenant(ctx) + if !isCross && tid == uuid.Nil { + return nil, nil + } + + selectCols := secureCLISelectColsAliased + + `, g.deny_args AS grant_deny_args, g.deny_verbose AS grant_deny_verbose, + g.timeout_seconds AS grant_timeout, g.tips AS grant_tips, g.id AS grant_id` + + query := `SELECT ` + selectCols + ` FROM secure_cli_binaries b + LEFT JOIN secure_cli_agent_grants g ON g.binary_id = b.id AND g.agent_id = $1 + WHERE b.enabled = true + AND ( + (b.is_global = true AND (g.id IS NULL OR g.enabled = true)) + OR + (b.is_global = false AND g.id IS NOT NULL AND g.enabled = true) + )` + + args := []any{agentID} + if !isCross { + query += ` AND b.tenant_id = $2` + args = append(args, tid) + } + query += ` ORDER BY b.binary_name` + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.SecureCLIBinary + for rows.Next() { + var b store.SecureCLIBinary + var binaryPath *string + var denyArgs, denyVerbose *[]byte + var env []byte + var grantDenyArgs, grantDenyVerbose *[]byte + var grantTimeout *int + var grantTips *string + var grantID *uuid.UUID + + if err := rows.Scan( + &b.ID, &b.BinaryName, &binaryPath, &b.Description, &env, + &denyArgs, &denyVerbose, + &b.TimeoutSeconds, &b.Tips, &b.IsGlobal, + &b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt, + &grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantID, + ); err != nil { + continue + } + + b.BinaryPath = binaryPath + if denyArgs != nil { + b.DenyArgs = *denyArgs + } + if denyVerbose != nil { + b.DenyVerbose = *denyVerbose + } + if len(env) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil { + b.EncryptedEnv = []byte(decrypted) + } + } else { + b.EncryptedEnv = env + } + + // Apply grant overrides + if grantID != nil { + grant := &store.SecureCLIAgentGrant{} + if grantDenyArgs != nil { + raw := json.RawMessage(*grantDenyArgs) + grant.DenyArgs = &raw + } + if grantDenyVerbose != nil { + raw := json.RawMessage(*grantDenyVerbose) + grant.DenyVerbose = &raw + } + grant.TimeoutSeconds = grantTimeout + grant.Tips = grantTips + b.MergeGrantOverrides(grant) + } + + result = append(result, b) + } + return result, nil +} diff --git a/internal/store/pg/secure_cli_agent_grants.go b/internal/store/pg/secure_cli_agent_grants.go new file mode 100644 index 00000000..075aa4ea --- /dev/null +++ b/internal/store/pg/secure_cli_agent_grants.go @@ -0,0 +1,200 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGSecureCLIAgentGrantStore implements store.SecureCLIAgentGrantStore backed by Postgres. +type PGSecureCLIAgentGrantStore struct { + db *sql.DB +} + +func NewPGSecureCLIAgentGrantStore(db *sql.DB) *PGSecureCLIAgentGrantStore { + return &PGSecureCLIAgentGrantStore{db: db} +} + +const grantSelectCols = `id, binary_id, agent_id, deny_args, deny_verbose, timeout_seconds, tips, enabled, created_at, updated_at` + +func (s *PGSecureCLIAgentGrantStore) Create(ctx context.Context, g *store.SecureCLIAgentGrant) error { + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + now := time.Now() + g.CreatedAt = now + g.UpdatedAt = now + + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_agent_grants + (id, binary_id, agent_id, deny_args, deny_verbose, timeout_seconds, tips, enabled, tenant_id, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, + g.ID, g.BinaryID, g.AgentID, + nullableJSON(g.DenyArgs), nullableJSON(g.DenyVerbose), + g.TimeoutSeconds, g.Tips, + g.Enabled, tenantID, now, now, + ) + return err +} + +func (s *PGSecureCLIAgentGrantStore) Get(ctx context.Context, id uuid.UUID) (*store.SecureCLIAgentGrant, error) { + query := `SELECT ` + grantSelectCols + ` FROM secure_cli_agent_grants WHERE id = $1` + args := []any{id} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, sql.ErrNoRows + } + query += ` AND tenant_id = $2` + args = append(args, tid) + } + row := s.db.QueryRowContext(ctx, query, args...) + return s.scanRow(row) +} + +var grantAllowedFields = map[string]bool{ + "deny_args": true, "deny_verbose": true, "timeout_seconds": true, + "tips": true, "enabled": true, "updated_at": true, +} + +func (s *PGSecureCLIAgentGrantStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error { + for k := range updates { + if !grantAllowedFields[k] { + delete(updates, k) + } + } + updates["updated_at"] = time.Now() + + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "secure_cli_agent_grants", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + return execMapUpdateWhereTenant(ctx, s.db, "secure_cli_agent_grants", updates, id, tid) +} + +func (s *PGSecureCLIAgentGrantStore) Delete(ctx context.Context, id uuid.UUID) error { + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_agent_grants WHERE id = $1", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + _, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_agent_grants WHERE id = $1 AND tenant_id = $2", id, tid) + return err +} + +func (s *PGSecureCLIAgentGrantStore) ListByBinary(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentGrant, error) { + query := `SELECT ` + grantSelectCols + ` FROM secure_cli_agent_grants WHERE binary_id = $1` + args := []any{binaryID} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, nil + } + query += ` AND tenant_id = $2` + args = append(args, tid) + } + query += ` ORDER BY created_at` + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + return s.scanRows(rows) +} + +func (s *PGSecureCLIAgentGrantStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIAgentGrant, error) { + query := `SELECT ` + grantSelectCols + ` FROM secure_cli_agent_grants WHERE agent_id = $1` + args := []any{agentID} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, nil + } + query += ` AND tenant_id = $2` + args = append(args, tid) + } + query += ` ORDER BY created_at` + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + return s.scanRows(rows) +} + +func (s *PGSecureCLIAgentGrantStore) scanRow(row *sql.Row) (*store.SecureCLIAgentGrant, error) { + var g store.SecureCLIAgentGrant + var denyArgs, denyVerbose *[]byte + var timeout *int + var tips *string + + err := row.Scan( + &g.ID, &g.BinaryID, &g.AgentID, + &denyArgs, &denyVerbose, &timeout, &tips, + &g.Enabled, &g.CreatedAt, &g.UpdatedAt, + ) + if err != nil { + return nil, err + } + s.applyNullable(&g, denyArgs, denyVerbose, timeout, tips) + return &g, nil +} + +func (s *PGSecureCLIAgentGrantStore) scanRows(rows *sql.Rows) ([]store.SecureCLIAgentGrant, error) { + defer rows.Close() + var result []store.SecureCLIAgentGrant + for rows.Next() { + var g store.SecureCLIAgentGrant + var denyArgs, denyVerbose *[]byte + var timeout *int + var tips *string + + if err := rows.Scan( + &g.ID, &g.BinaryID, &g.AgentID, + &denyArgs, &denyVerbose, &timeout, &tips, + &g.Enabled, &g.CreatedAt, &g.UpdatedAt, + ); err != nil { + continue + } + s.applyNullable(&g, denyArgs, denyVerbose, timeout, tips) + result = append(result, g) + } + return result, nil +} + +// applyNullable converts scanned nullable values to pointer fields on the grant struct. +func (s *PGSecureCLIAgentGrantStore) applyNullable(g *store.SecureCLIAgentGrant, denyArgs, denyVerbose *[]byte, timeout *int, tips *string) { + if denyArgs != nil { + raw := json.RawMessage(*denyArgs) + g.DenyArgs = &raw + } + if denyVerbose != nil { + raw := json.RawMessage(*denyVerbose) + g.DenyVerbose = &raw + } + g.TimeoutSeconds = timeout + g.Tips = tips +} + +// nullableJSON returns nil if the pointer is nil, otherwise the raw bytes for the DB driver. +func nullableJSON(v *json.RawMessage) any { + if v == nil { + return nil + } + return []byte(*v) +} diff --git a/internal/store/secure_cli_store.go b/internal/store/secure_cli_store.go index b7d86172..4ad54e4c 100644 --- a/internal/store/secure_cli_store.go +++ b/internal/store/secure_cli_store.go @@ -3,6 +3,7 @@ package store import ( "context" "encoding/json" + "time" "github.com/google/uuid" ) @@ -19,7 +20,7 @@ type SecureCLIBinary struct { DenyVerbose json.RawMessage `json:"deny_verbose"` // blocked verbose/debug flags TimeoutSeconds int `json:"timeout_seconds"` Tips string `json:"tips"` // hint injected into TOOLS.md context - AgentID *uuid.UUID `json:"agent_id,omitempty"` + IsGlobal bool `json:"is_global"` Enabled bool `json:"enabled"` CreatedBy string `json:"created_by"` UserEnv []byte `json:"-"` // per-user encrypted env (populated by LookupByBinary LEFT JOIN) @@ -27,6 +28,26 @@ type SecureCLIBinary struct { EnvKeys []string `json:"env_keys,omitempty"` } +// 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) { + if g == nil { + return + } + if g.DenyArgs != nil { + b.DenyArgs = *g.DenyArgs + } + if g.DenyVerbose != nil { + b.DenyVerbose = *g.DenyVerbose + } + if g.TimeoutSeconds != nil { + b.TimeoutSeconds = *g.TimeoutSeconds + } + if g.Tips != nil { + b.Tips = *g.Tips + } +} + // SecureCLIUserCredential holds per-user encrypted env overrides for a binary. type SecureCLIUserCredential struct { ID uuid.UUID `json:"id"` @@ -39,6 +60,20 @@ type SecureCLIUserCredential struct { EncryptedEnv []byte `json:"-"` } +// SecureCLIAgentGrant represents a per-agent grant with optional setting overrides. +type SecureCLIAgentGrant struct { + BaseModel + BinaryID uuid.UUID `json:"binary_id"` + AgentID uuid.UUID `json:"agent_id"` + DenyArgs *json.RawMessage `json:"deny_args,omitempty"` + DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty"` + TimeoutSeconds *int `json:"timeout_seconds,omitempty"` + Tips *string `json:"tips,omitempty"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // SecureCLIStore manages secure CLI binary credential configurations. type SecureCLIStore interface { Create(ctx context.Context, b *SecureCLIBinary) error @@ -46,17 +81,19 @@ type SecureCLIStore interface { Update(ctx context.Context, id uuid.UUID, updates map[string]any) error Delete(ctx context.Context, id uuid.UUID) error List(ctx context.Context) ([]SecureCLIBinary, error) - ListByAgent(ctx context.Context, agentID uuid.UUID) ([]SecureCLIBinary, error) - // LookupByBinary finds the best-matching credential config for a binary name. - // Priority: agent-specific > global (agent_id IS NULL). Returns nil if not found. - // If userID is non-empty, also fetches per-user env overrides via LEFT JOIN - // and populates SecureCLIBinary.UserEnv (zero extra queries). + // LookupByBinary finds the credential config for a binary name. + // If agentID is provided, checks grant authorization and merges overrides. + // If userID is non-empty, also fetches per-user env overrides via LEFT JOIN. LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID, userID string) (*SecureCLIBinary, error) // ListEnabled returns all enabled configs (for TOOLS.md context generation). ListEnabled(ctx context.Context) ([]SecureCLIBinary, error) + // ListForAgent returns all CLIs accessible by an agent (global + granted), + // with grant overrides merged into the returned configs. + ListForAgent(ctx context.Context, agentID uuid.UUID) ([]SecureCLIBinary, error) + // --- Per-user credential management --- GetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) (*SecureCLIUserCredential, error) @@ -64,3 +101,13 @@ type SecureCLIStore interface { DeleteUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) error ListUserCredentials(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIUserCredential, error) } + +// SecureCLIAgentGrantStore manages per-agent grants for secure CLI binaries. +type SecureCLIAgentGrantStore interface { + Create(ctx context.Context, g *SecureCLIAgentGrant) error + Get(ctx context.Context, id uuid.UUID) (*SecureCLIAgentGrant, error) + Update(ctx context.Context, id uuid.UUID, updates map[string]any) error + Delete(ctx context.Context, id uuid.UUID) error + ListByBinary(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIAgentGrant, error) + ListByAgent(ctx context.Context, agentID uuid.UUID) ([]SecureCLIAgentGrant, error) +} diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index 26dcad74..82252fe6 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -14,7 +14,7 @@ var schemaSQL string // SchemaVersion is the current SQLite schema version. // Bump this when adding new migration steps below. -const SchemaVersion = 5 +const SchemaVersion = 6 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -78,6 +78,28 @@ CREATE UNIQUE INDEX idx_channel_contacts_tenant_type_sender CREATE INDEX IF NOT EXISTS idx_subagent_tasks_parent_status ON subagent_tasks(tenant_id, parent_agent_key, status); CREATE INDEX IF NOT EXISTS idx_subagent_tasks_session ON subagent_tasks(session_key); CREATE INDEX IF NOT EXISTS idx_subagent_tasks_created ON subagent_tasks(tenant_id, created_at);`, + // Version 5 → 6: secure CLI agent grants — replace agent_id with is_global + grants table. + 5: `ALTER TABLE secure_cli_binaries ADD COLUMN is_global BOOLEAN NOT NULL DEFAULT 1; +DROP INDEX IF EXISTS idx_secure_cli_unique_binary_agent; +DROP INDEX IF EXISTS idx_secure_cli_agent_id; +CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_unique_binary_tenant ON secure_cli_binaries(binary_name, tenant_id); +CREATE TABLE IF NOT EXISTS secure_cli_agent_grants ( + id TEXT NOT NULL PRIMARY KEY, + binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + deny_args TEXT, + deny_verbose TEXT, + timeout_seconds INTEGER, + tips TEXT, + enabled BOOLEAN NOT NULL DEFAULT 1, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(binary_id, agent_id, tenant_id) +); +CREATE INDEX IF NOT EXISTS idx_scag_binary ON secure_cli_agent_grants(binary_id); +CREATE INDEX IF NOT EXISTS idx_scag_agent ON secure_cli_agent_grants(agent_id); +CREATE INDEX IF NOT EXISTS idx_scag_tenant ON secure_cli_agent_grants(tenant_id);`, } // EnsureSchema creates tables if they don't exist and applies incremental migrations. diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index a869974a..ddc42cb9 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -1175,7 +1175,7 @@ CREATE TABLE IF NOT EXISTS secure_cli_binaries ( deny_verbose TEXT NOT NULL DEFAULT '[]', timeout_seconds INTEGER NOT NULL DEFAULT 30, tips TEXT NOT NULL DEFAULT '', - agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE, + is_global BOOLEAN NOT NULL DEFAULT 1, enabled BOOLEAN NOT NULL DEFAULT 1, created_by TEXT NOT NULL DEFAULT '', tenant_id TEXT NOT NULL REFERENCES tenants(id), @@ -1184,11 +1184,32 @@ CREATE TABLE IF NOT EXISTS secure_cli_binaries ( ); CREATE INDEX IF NOT EXISTS idx_secure_cli_binary_name ON secure_cli_binaries(binary_name); -CREATE INDEX IF NOT EXISTS idx_secure_cli_agent_id ON secure_cli_binaries(agent_id) WHERE agent_id IS NOT NULL; --- COALESCE(agent_id, '') simulates the PG COALESCE(agent_id, null-sentinel) unique constraint -CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_unique_binary_agent ON secure_cli_binaries(binary_name, COALESCE(agent_id, '')); +CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_unique_binary_tenant ON secure_cli_binaries(binary_name, tenant_id); CREATE INDEX IF NOT EXISTS idx_secure_cli_binaries_tenant ON secure_cli_binaries(tenant_id); +-- ============================================================ +-- Table: secure_cli_agent_grants +-- ============================================================ + +CREATE TABLE IF NOT EXISTS secure_cli_agent_grants ( + id TEXT NOT NULL PRIMARY KEY, + binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + deny_args TEXT, + deny_verbose TEXT, + timeout_seconds INTEGER, + tips TEXT, + enabled BOOLEAN NOT NULL DEFAULT 1, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(binary_id, agent_id, tenant_id) +); + +CREATE INDEX IF NOT EXISTS idx_scag_binary ON secure_cli_agent_grants(binary_id); +CREATE INDEX IF NOT EXISTS idx_scag_agent ON secure_cli_agent_grants(agent_id); +CREATE INDEX IF NOT EXISTS idx_scag_tenant ON secure_cli_agent_grants(tenant_id); + -- ============================================================ -- Table: api_keys -- ============================================================ diff --git a/internal/store/stores.go b/internal/store/stores.go index 9f951fb6..54d3c5fd 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -24,8 +24,9 @@ type Stores struct { Contacts ContactStore Activity ActivityStore Snapshots SnapshotStore - SecureCLI SecureCLIStore - APIKeys APIKeyStore + SecureCLI SecureCLIStore + SecureCLIGrants SecureCLIAgentGrantStore + APIKeys APIKeyStore Heartbeats HeartbeatStore ConfigPermissions ConfigPermissionStore Tenants TenantStore diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index ed5f6ffd..53f28a10 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 = 35 +const RequiredSchemaVersion uint = 36 diff --git a/migrations/000036_secure_cli_agent_grants.down.sql b/migrations/000036_secure_cli_agent_grants.down.sql new file mode 100644 index 00000000..cb254406 --- /dev/null +++ b/migrations/000036_secure_cli_agent_grants.down.sql @@ -0,0 +1,10 @@ +-- Reverse: add agent_id back, drop is_global, drop grants table +ALTER TABLE secure_cli_binaries ADD COLUMN agent_id UUID REFERENCES agents(id) ON DELETE CASCADE; + +DROP INDEX IF EXISTS idx_secure_cli_unique_binary_tenant; +CREATE UNIQUE INDEX idx_secure_cli_unique_binary_agent + ON secure_cli_binaries(binary_name, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid)); +CREATE INDEX idx_secure_cli_agent_id ON secure_cli_binaries(agent_id) WHERE agent_id IS NOT NULL; + +ALTER TABLE secure_cli_binaries DROP COLUMN IF EXISTS is_global; +DROP TABLE IF EXISTS secure_cli_agent_grants; diff --git a/migrations/000036_secure_cli_agent_grants.up.sql b/migrations/000036_secure_cli_agent_grants.up.sql new file mode 100644 index 00000000..a3c83a3b --- /dev/null +++ b/migrations/000036_secure_cli_agent_grants.up.sql @@ -0,0 +1,95 @@ +-- Per-agent grants for secure CLI binaries with optional setting overrides. +-- Separates "which agents can use a binary" from "binary credential definition". + +-- 1. Create agent grants table +CREATE TABLE secure_cli_agent_grants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + binary_id UUID NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + deny_args JSONB, -- NULL = use binary default + deny_verbose JSONB, -- NULL = use binary default + timeout_seconds INTEGER, -- NULL = use binary default + tips TEXT, -- NULL = use binary default + enabled BOOLEAN NOT NULL DEFAULT true, + tenant_id UUID NOT NULL REFERENCES tenants(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(binary_id, agent_id, tenant_id) +); + +CREATE INDEX idx_scag_binary ON secure_cli_agent_grants(binary_id); +CREATE INDEX idx_scag_agent ON secure_cli_agent_grants(agent_id); +CREATE INDEX idx_scag_tenant ON secure_cli_agent_grants(tenant_id); + +-- 2. Add is_global column (default true for backward compat) +ALTER TABLE secure_cli_binaries ADD COLUMN is_global BOOLEAN NOT NULL DEFAULT true; + +-- 3. Migrate agent-specific rows to grants table. +-- Copy settings from agent-specific binaries into grants. +INSERT INTO secure_cli_agent_grants (binary_id, agent_id, deny_args, deny_verbose, timeout_seconds, tips, enabled, tenant_id) +SELECT id, agent_id, deny_args, deny_verbose, timeout_seconds, tips, enabled, tenant_id +FROM secure_cli_binaries +WHERE agent_id IS NOT NULL; + +-- 4. For agent-specific rows that HAVE a matching global row (same binary_name + tenant): +-- Re-point grants to the global row, then delete the now-orphaned agent-specific row. +UPDATE secure_cli_agent_grants g +SET binary_id = sub.global_id +FROM ( + SELECT g2.id AS grant_id, b_global.id AS global_id + FROM secure_cli_agent_grants g2 + JOIN secure_cli_binaries b_agent ON b_agent.id = g2.binary_id AND b_agent.agent_id IS NOT NULL + JOIN secure_cli_binaries b_global ON b_global.binary_name = b_agent.binary_name + AND b_global.tenant_id = b_agent.tenant_id + AND b_global.agent_id IS NULL +) sub +WHERE g.id = sub.grant_id; + +DELETE FROM secure_cli_binaries +WHERE agent_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM secure_cli_binaries b2 + WHERE b2.binary_name = secure_cli_binaries.binary_name + AND b2.tenant_id = secure_cli_binaries.tenant_id + AND b2.agent_id IS NULL + ); + +-- 5. For agent-specific rows WITHOUT a global counterpart: +-- Dedup: keep the row with the smallest id per (binary_name, tenant_id) as the +-- canonical binary definition. Re-point grants from duplicates to the keeper, then delete dupes. + +-- 5a. Re-point grants from duplicate rows to the canonical (MIN id) row. +UPDATE secure_cli_agent_grants g +SET binary_id = keeper.keeper_id +FROM ( + SELECT b.id AS dup_id, first_value(b.id) OVER ( + PARTITION BY b.binary_name, b.tenant_id ORDER BY b.id + ) AS keeper_id + FROM secure_cli_binaries b + WHERE b.agent_id IS NOT NULL +) keeper +WHERE g.binary_id = keeper.dup_id + AND keeper.dup_id != keeper.keeper_id; + +-- 5b. Delete duplicate rows (keep only the canonical per binary_name+tenant_id). +DELETE FROM secure_cli_binaries +WHERE agent_id IS NOT NULL + AND id NOT IN ( + SELECT DISTINCT ON (binary_name, tenant_id) id + FROM secure_cli_binaries + WHERE agent_id IS NOT NULL + ORDER BY binary_name, tenant_id, id + ); + +-- 5c. Mark remaining agent-specific rows as restricted (is_global = false). +UPDATE secure_cli_binaries SET is_global = false +WHERE agent_id IS NOT NULL; + +-- 6. Drop agent_id column and old indexes. +DROP INDEX IF EXISTS idx_secure_cli_unique_binary_agent; +DROP INDEX IF EXISTS idx_secure_cli_agent_id; +ALTER TABLE secure_cli_binaries DROP COLUMN agent_id; + +-- 7. New unique constraint: one binary per name per tenant. +CREATE UNIQUE INDEX idx_secure_cli_unique_binary_tenant + ON secure_cli_binaries(binary_name, tenant_id); diff --git a/ui/web/src/i18n/locales/en/cli-credentials.json b/ui/web/src/i18n/locales/en/cli-credentials.json index 282766bc..99ac5c72 100644 --- a/ui/web/src/i18n/locales/en/cli-credentials.json +++ b/ui/web/src/i18n/locales/en/cli-credentials.json @@ -7,7 +7,8 @@ "columns": { "binary": "Binary", "scope": "Scope", - "timeout": "Timeout" + "timeout": "Timeout", + "restricted": "Restricted" }, "delete": { "title": "Delete CLI Credential", @@ -43,7 +44,9 @@ "checkBinary": "Check", "binaryFound": "Found at {{path}}", "binaryNotFound": "Binary not found in server PATH", - "checking": "Checking..." + "checking": "Checking...", + "isGlobal": "Available to all agents", + "isGlobalHint": "When off, only agents with explicit grants can use this CLI" }, "placeholders": { "binaryName": "e.g. gh", @@ -83,5 +86,29 @@ "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." + }, + "grants": { + "title": "Agent Grants — {{name}}", + "currentGrants": "Current Grants", + "addGrant": "Add Grant", + "editGrant": "Edit Grant", + "selectAgent": "Select agent...", + "usingDefaults": "Using binary defaults", + "overrideTimeout": "Override timeout (seconds)", + "overrideDenyArgs": "Override deny args", + "overrideDenyVerbose": "Override deny verbose", + "overrideTips": "Override tips", + "defaultPlaceholder": "default", + "grant": "Grant", + "update": "Update", + "agentRequired": "Please select an agent", + "toast": { + "granted": "Agent grant created", + "grantFailed": "Failed to create grant", + "updated": "Agent grant updated", + "updateFailed": "Failed to update grant", + "revoked": "Agent grant revoked", + "revokeFailed": "Failed to revoke grant" + } } } diff --git a/ui/web/src/i18n/locales/vi/cli-credentials.json b/ui/web/src/i18n/locales/vi/cli-credentials.json index ae341042..32eb007c 100644 --- a/ui/web/src/i18n/locales/vi/cli-credentials.json +++ b/ui/web/src/i18n/locales/vi/cli-credentials.json @@ -7,7 +7,8 @@ "columns": { "binary": "Binary", "scope": "Phạm vi", - "timeout": "Thời gian chờ" + "timeout": "Thời gian chờ", + "restricted": "Giới hạn" }, "delete": { "title": "Xóa thông tin CLI", @@ -43,7 +44,9 @@ "checkBinary": "Kiểm tra", "binaryFound": "Tìm thấy tại {{path}}", "binaryNotFound": "Không tìm thấy binary trên server", - "checking": "Đang kiểm tra..." + "checking": "Đang kiểm tra...", + "isGlobal": "Khả dụng cho tất cả agent", + "isGlobalHint": "Khi tắt, chỉ agent có grant mới dùng được CLI này" }, "placeholders": { "binaryName": "vd: gh", @@ -83,5 +86,29 @@ "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." + }, + "grants": { + "title": "Phân quyền Agent — {{name}}", + "currentGrants": "Quyền hiện tại", + "addGrant": "Thêm quyền", + "editGrant": "Sửa quyền", + "selectAgent": "Chọn agent...", + "usingDefaults": "Sử dụng cấu hình mặc định", + "overrideTimeout": "Ghi đè timeout (giây)", + "overrideDenyArgs": "Ghi đè deny args", + "overrideDenyVerbose": "Ghi đè deny verbose", + "overrideTips": "Ghi đè tips", + "defaultPlaceholder": "mặc định", + "grant": "Cấp quyền", + "update": "Cập nhật", + "agentRequired": "Vui lòng chọn agent", + "toast": { + "granted": "Đã cấp quyền agent", + "grantFailed": "Cấp quyền thất bại", + "updated": "Đã cập nhật quyền", + "updateFailed": "Cập nhật quyền thất bại", + "revoked": "Đã thu hồi quyền", + "revokeFailed": "Thu hồi quyền thất bại" + } } } diff --git a/ui/web/src/i18n/locales/zh/cli-credentials.json b/ui/web/src/i18n/locales/zh/cli-credentials.json index 9a5eeb2b..142a26c0 100644 --- a/ui/web/src/i18n/locales/zh/cli-credentials.json +++ b/ui/web/src/i18n/locales/zh/cli-credentials.json @@ -7,7 +7,8 @@ "columns": { "binary": "二进制文件", "scope": "范围", - "timeout": "超时" + "timeout": "超时", + "restricted": "受限" }, "delete": { "title": "删除 CLI 凭证", @@ -43,7 +44,9 @@ "checkBinary": "检查", "binaryFound": "已找到:{{path}}", "binaryNotFound": "在服务器 PATH 中未找到", - "checking": "检查中..." + "checking": "检查中...", + "isGlobal": "对所有代理可用", + "isGlobalHint": "关闭后,只有明确授权的代理才能使用此CLI" }, "placeholders": { "binaryName": "例如 gh", @@ -83,5 +86,29 @@ "deleteFailed": "删除用户凭证失败", "envRequired": "至少需要一个环境变量", "mergeHint": "聊天用户(Telegram、Discord 等)需要先通过联系人页面合并为租户用户,才能设置独立凭证。" + }, + "grants": { + "title": "代理授权 — {{name}}", + "currentGrants": "当前授权", + "addGrant": "添加授权", + "editGrant": "编辑授权", + "selectAgent": "选择代理...", + "usingDefaults": "使用默认配置", + "overrideTimeout": "覆盖超时(秒)", + "overrideDenyArgs": "覆盖拒绝参数", + "overrideDenyVerbose": "覆盖拒绝详细参数", + "overrideTips": "覆盖提示", + "defaultPlaceholder": "默认", + "grant": "授权", + "update": "更新", + "agentRequired": "请选择代理", + "toast": { + "granted": "代理授权已创建", + "grantFailed": "创建授权失败", + "updated": "代理授权已更新", + "updateFailed": "更新授权失败", + "revoked": "代理授权已撤销", + "revokeFailed": "撤销授权失败" + } } } diff --git a/ui/web/src/pages/cli-credentials/cli-credential-form-dialog.tsx b/ui/web/src/pages/cli-credentials/cli-credential-form-dialog.tsx index 73cb6955..e7cfc08e 100644 --- a/ui/web/src/pages/cli-credentials/cli-credential-form-dialog.tsx +++ b/ui/web/src/pages/cli-credentials/cli-credential-form-dialog.tsx @@ -12,7 +12,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useHttp } from "@/hooks/use-ws"; -import { useAgents } from "@/pages/agents/hooks/use-agents"; import type { SecureCLIBinary, CLICredentialInput, CLIPreset } from "./hooks/use-cli-credentials"; import { CliCredentialEnvVarsSection } from "./cli-credential-env-vars-section"; import { CliCredentialBinaryFields } from "./cli-credential-binary-fields"; @@ -34,7 +33,6 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset const { t } = useTranslation("cli-credentials"); const { t: tc } = useTranslation("common"); const http = useHttp(); - const { agents } = useAgents(); const [selectedPreset, setSelectedPreset] = useState(NONE_PRESET); const [envValues, setEnvValues] = useState>({}); @@ -63,7 +61,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset denyVerbose: "", timeout: 30, tips: "", - agentId: "", + isGlobal: true, enabled: true, }, }); @@ -80,7 +78,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset denyVerbose: (credential?.deny_verbose ?? []).join(", "), timeout: credential?.timeout_seconds ?? 30, tips: credential?.tips ?? "", - agentId: credential?.agent_id ?? "", + isGlobal: credential?.is_global ?? true, enabled: credential?.enabled ?? true, }); setEnvValues({}); @@ -129,7 +127,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset denyVerbose: p.deny_verbose.join(", "), timeout: p.timeout, tips: p.tips, - agentId: "", + isGlobal: true, enabled: true, }); setEnvValues({}); @@ -184,7 +182,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset deny_verbose: splitCommaList(values.denyVerbose ?? ""), timeout_seconds: values.timeout, tips: values.tips?.trim() ?? "", - agent_id: values.agentId?.trim() || undefined, + is_global: values.isGlobal, enabled: values.enabled, }; if (selectedPreset !== NONE_PRESET) payload.preset = selectedPreset; @@ -254,7 +252,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset onCheckBinary={handleCheckBinary} /> - + {error &&

{error}

} diff --git a/ui/web/src/pages/cli-credentials/cli-credential-grant-card.tsx b/ui/web/src/pages/cli-credentials/cli-credential-grant-card.tsx new file mode 100644 index 00000000..4be1b3cb --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-credential-grant-card.tsx @@ -0,0 +1,76 @@ +import { useTranslation } from "react-i18next"; +import { Trash2, Pencil } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { CLIAgentGrant } from "./hooks/use-cli-credentials"; + +interface Props { + grant: CLIAgentGrant; + agentName: string; + isActive: boolean; + disabled: boolean; + onSelect: () => void; + onRevoke: () => void; +} + +/** Renders a single agent grant card in the grants list. */ +export function CliCredentialGrantCard({ grant, agentName, isActive, disabled, onSelect, onRevoke }: Props) { + const { t } = useTranslation("cli-credentials"); + const { t: tc } = useTranslation("common"); + + const hasOverrides = grant.deny_args || grant.deny_verbose || + grant.timeout_seconds != null || grant.tips; + + return ( +
+
+
+
+ {agentName} + {!grant.enabled && ( + {tc("disabled")} + )} + {isActive && } +
+ {hasOverrides ? ( +
+ {grant.timeout_seconds != null && ( + + timeout: {grant.timeout_seconds}s + + )} + {grant.deny_args && ( + + deny: {grant.deny_args.length} rules + + )} + {grant.tips && ( + + tips: {grant.tips} + + )} +
+ ) : ( +

{t("grants.usingDefaults")}

+ )} +
+ +
+
+ ); +} diff --git a/ui/web/src/pages/cli-credentials/cli-credential-grant-form.tsx b/ui/web/src/pages/cli-credentials/cli-credential-grant-form.tsx new file mode 100644 index 00000000..e10e4044 --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-credential-grant-form.tsx @@ -0,0 +1,132 @@ +import { useTranslation } from "react-i18next"; +import { Plus, Pencil } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +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; + denyArgs: string; + setDenyArgs: (v: string) => void; + denyVerbose: string; + setDenyVerbose: (v: string) => void; + timeout: string; + setTimeout: (v: string) => void; + tips: string; + setTips: (v: string) => void; + enabled: boolean; + setEnabled: (v: boolean) => void; + isEditing: boolean; + saving: boolean; + onSubmit: () => void; + onCancel: () => void; +} + +/** Inline form for adding or editing a per-agent grant. */ +export function CliCredentialGrantForm({ + binary, agents, agentId, setAgentId, + denyArgs, setDenyArgs, denyVerbose, setDenyVerbose, + timeout, setTimeout, tips, setTips, + enabled, setEnabled, isEditing, saving, + onSubmit, onCancel, +}: Props) { + const { t } = useTranslation("cli-credentials"); + const { t: tc } = useTranslation("common"); + + return ( +
+
+ + {isEditing && ( + + )} +
+ +
+ + +
+ + setDenyArgs(e.target.value)} + placeholder={binary.deny_args?.join(", ") || t("grants.defaultPlaceholder")} + className="text-base md:text-sm" + /> +
+ +
+ + setDenyVerbose(e.target.value)} + placeholder={binary.deny_verbose?.join(", ") || t("grants.defaultPlaceholder")} + className="text-base md:text-sm" + /> +
+ +
+ + setTimeout(e.target.value)} + placeholder={`${binary.timeout_seconds} (${t("grants.defaultPlaceholder")})`} + className="text-base md:text-sm" + /> +
+ +
+ +