Merge pull request #120 from digitopvn/codex/issue-117-agent-scoped-git-credentials-plan

feat(cli-credentials): add agent-scoped git credentials
This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-05-31 17:40:50 +07:00
commit 10e663f21d
51 files changed
+3060 -85

No files matched your search

+10 -2
View File
@@ -13,6 +13,7 @@ type Service struct {
onJob JobHandler
running bool
stopChan chan struct{}
wg sync.WaitGroup
mu sync.Mutex
runLog []RunLogEntry // in-memory run history (last 200 entries)
retryCfg RetryConfig // retry config for failed jobs
@@ -89,7 +90,11 @@ func (cs *Service) Start() error {
// after a previous Stop() returned but the runLoop goroutine hasn't yet
// executed its ticker construction.
tick := runLoopTickInterval
go cs.runLoop(cs.stopChan, tick)
cs.wg.Add(1)
go func() {
defer cs.wg.Done()
cs.runLoop(cs.stopChan, tick)
}()
slog.Info("cron service started", "jobs", len(cs.store.Jobs))
return nil
@@ -98,14 +103,17 @@ func (cs *Service) Start() error {
// Stop halts the scheduling loop.
func (cs *Service) Stop() {
cs.mu.Lock()
defer cs.mu.Unlock()
if !cs.running {
cs.mu.Unlock()
return
}
close(cs.stopChan)
cs.running = false
cs.mu.Unlock()
cs.wg.Wait()
slog.Info("cron service stopped")
}
+2
View File
@@ -216,7 +216,9 @@ func (cs *Service) checkJobs() {
// agent loop stuck), the entire cron scheduler would stop checking for new
// due jobs. Now each job runs independently with panic recovery.
for _, dj := range dueJobs {
cs.wg.Add(1)
go func(id string, scheduledAtMS int64) {
defer cs.wg.Done()
defer safego.Recover(nil, "job_id", id)
cs.executeJobByID(id, scheduledAtMS)
}(dj.id, dj.scheduledAtMS)
+20 -4
View File
@@ -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 {
@@ -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"})
}
@@ -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')
}
+49 -10
View File
@@ -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
}
@@ -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
}
+57
View File
@@ -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)
+25 -1
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 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 = `
+30
View File
@@ -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)
-- ============================================================
@@ -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
}
+50 -14
View File
@@ -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
}
@@ -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)
}
}
+5 -3
View File
@@ -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),
+7 -4
View File
@@ -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))
+47 -7
View File
@@ -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 {
@@ -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{
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 76
const RequiredSchemaVersion uint = 77