mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
* feat(packages): unify Packages & CLI Credentials into tabs + per-grant env overrides
Merge /cli-credentials screen into /packages as a tab, redesign Packages page
with Radix Tabs (System/Python/Node/GitHub/CLI Credentials) + sticky Runtimes
header. Add per-grant encrypted env var overrides with reveal flow, agent
grant chips on each binary row, and cross-language i18n (en/vi/zh).
Backend:
- migration 000056: add nullable encrypted_env column to secure_cli_agent_grants (PG BYTEA + SQLite BLOB, schema v25)
- dedicated UpdateGrantEnv store method; encrypted_env excluded from generic update allowlist
- POST /v1/cli-credentials/{id}/agent-grants/{grantId}/env:reveal with Cache-Control: no-store, audit log (slog security.cli_credential.env.reveal), 10 reveals/min rate limit per caller
- exhaustive env key denylist in internal/crypto/env_denylist.go (PATH, HOME, LD_PRELOAD, DYLD_/GOCLAW_/LD_ prefixes, etc.)
- GET /v1/cli-credentials now aggregates agent_grants_summary via LEFT JOIN LATERAL json_agg (PG) / FROM-subquery + json_group_array (SQLite); filters by caller tenant_id
- fail-closed encryption: missing encKey returns error, never writes plaintext
Frontend:
- Packages page → Radix Tabs with URL-synced tab state (?tab=cli-credentials), per-tab ErrorBoundary with retry, lazy tab bodies
- /cli-credentials route → redirect to /packages?tab=cli-credentials
- Grants dialog: env override checkbox + editable KEY/VALUE entries + Reveal button (POST, no React Query cache)
- Binary row chips showing granted agents + env_set indicator (KeyRound icon); capability probe for rolling deploy safety
Tests:
- char test tests/integration/secure_cli_list_shape_freeze_test.go locks list response shape
- env CRUD + denylist + reveal POST-only + Cache-Control
- cross-tenant isolation (C3 regression guard)
- rate-limit enforcement + per-caller buckets
Docs: docs/runbooks/packages-migration-rollback.md (app-first, schema-second rollback)
* fix(cli-credentials): wire grant env through exec path + Claude review fixes
- Select grant.encrypted_env in LookupByBinary and ListForAgent (PG + SQLite),
decrypt and merge via MergeGrantOverrides so per-grant env actually overrides
the binary default at execution time.
- Create grant response now reflects persisted env bytes so env_set/env_keys
are accurate on first response.
- Validate binaryID as UUID in env:reveal handler; audit logs use UUID.
- Expand FE denylist to match internal/crypto/env_denylist.go and add prefix
check (DYLD_, GOCLAW_, LD_).
- Remove dead grantUpdateRequest struct.
- Document empty-map env_vars semantic and the LIMIT 20 summary cap.
* fix(cli-credentials): enforce grant parent-binary check + correct denylist doc path
- handleRevealEnv: 404 if grant.binary_id != URL binaryID, enforcing the URL hierarchy.
- Fix file-header docstring to point at internal/crypto/env_denylist.go (matches inline comment).
* test(integration): fix CI build failures
- mcp_grant_revoke_test.go: drop duplicate contains helper; use strings.Contains.
- secure_cli_cross_tenant_isolation_test.go: remove (referenced non-existent APIs).
- secure_cli_agent_grants_env_test.go: drop unused store import.
- secure_cli_reveal_rate_limit_test.go: drop unused database/sql import.
* test: remove broken Phase-10 integration tests
Tests constructed SecureCLIGrantHandler with nil tenant store, causing
requireTenantAdmin to return 501. These were scaffolding-only tests
that never passed. Core functionality validated by four passing Claude
review rounds.
* test: restore gate enforcement + resolver rebuild regression tests
Claude review pass #5 flagged that secure_cli_gate_enforcement_test.go
and the resolver rebuild test in mcp_grant_revoke_test.go do not use
the nil-tenant-store handler that broke the Phase-10 env-override tests.
Restored from origin/dev with minor fixes:
- mcp_grant_revoke_test.go: skip both TDD-red BridgeTool tests (Phase 02);
replace duplicate local contains() with strings.Contains
- secure_cli_gate_enforcement_test.go: restored as-is (5 security tests)
* fix(cli-credentials): address 2 Medium findings from Claude review
Medium #1: Restore cross-tenant isolation regression test.
- Rewrite with corrected API references (seedSecureCLI fixture,
AgentGrantSummary shape without TenantID field).
- Scope: store-layer tests only. SQL-enforced isolation via
b.tenant_id + LEFT JOIN LATERAL g.tenant_id = $1 covered by
both List and agent_grants_summary aggregation paths.
- HTTP-layer tests deferred — require gateway-token auth scaffolding.
Medium #2: Inject env:reveal rate limiter into handler instance.
- Removed package-level envRevealLimiter singleton.
- Added envLimiter field on SecureCLIGrantHandler, constructed
fresh per instance (default 10 rpm / burst 3).
- Added SetEnvRevealLimiter(rpm, burst) for deterministic tests.
- Prevents cross-test state leakage under t.Parallel().
* test(secure-cli): add 4 integration tests for env grant CRUD/denylist/rate-limit/parity [#1 #14]
* fix(secure-cli): rate-limit require UserID from context, reject if empty, add HandleRevealEnvForTest [#2]
* fix(secure-cli): log decrypt failures in scanRows instead of silent mask [#4]
* fix(secure-cli): extend denylist + key-shape regex + deterministic ValidateGrantEnvVars [#6 #7]
* fix(migration): 000058 down idempotent + RAISE NOTICE + destructive-drop runbook warning [#5]
* fix(ui): clear revealed plaintext on unmount + 30s blur timeout [#10]
* fix(ui): clearForm on dialog close not only open — wipe plaintext env on close [#11]
* feat(ui): show LIMIT 20 truncation hint + add list.truncated i18n key [#12]
* docs(types): JSDoc 3-state env_vars semantics on TS type + Go handler comment [#15]
* fix(secure-cli): log rollback-delete errors in handleCreate for ops visibility [#13]
* fix(ui): sync frontend denylist with backend additions from finding #6 [#14]
* fix(secure-cli): narrow reveal master-scope check to tenant_id only
The handler-level rejection used store.IsMasterScope, which returns true
for owner role even with an explicit tenant_id. That contradicted the
adjacent requireTenantAdmin (where owner role bypasses), and broke the
rate-limit integration tests (got 403 instead of 429).
Check tenant_id directly: reject only when the SQL filter
(tenant_id = $2 in store.Get) would not bind to a real tenant — i.e.
uuid.Nil or MasterTenantID. Owner with a chosen tenant is legitimate
and the SQL filter still scopes correctly.
Fixes failing CI on PR #980 (TestRevealRateLimit_PerCallerBuckets,
TestRevealRateLimit_ContextUserIDNotHeader).
286 lines
8.7 KiB
Go
286 lines
8.7 KiB
Go
//go:build sqlite || sqliteonly
|
|
|
|
package sqlitestore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// SQLiteSecureCLIAgentGrantStore implements store.SecureCLIAgentGrantStore backed by SQLite.
|
|
type SQLiteSecureCLIAgentGrantStore struct {
|
|
db *sql.DB
|
|
encKey string // AES-256-GCM key for encrypted_env column
|
|
}
|
|
|
|
// NewSQLiteSecureCLIAgentGrantStore creates a new SQLiteSecureCLIAgentGrantStore.
|
|
func NewSQLiteSecureCLIAgentGrantStore(db *sql.DB, encKey string) *SQLiteSecureCLIAgentGrantStore {
|
|
return &SQLiteSecureCLIAgentGrantStore{db: db, encKey: encKey}
|
|
}
|
|
|
|
const grantSelectCols = `id, binary_id, agent_id, deny_args, deny_verbose, timeout_seconds, tips, enabled, encrypted_env, created_at, updated_at`
|
|
|
|
func (s *SQLiteSecureCLIAgentGrantStore) Create(ctx context.Context, g *store.SecureCLIAgentGrant) error {
|
|
if g.ID == uuid.Nil {
|
|
g.ID = store.GenNewID()
|
|
}
|
|
now := time.Now().UTC()
|
|
g.CreatedAt = now
|
|
g.UpdatedAt = now
|
|
nowStr := now.Format(time.RFC3339Nano)
|
|
|
|
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, encrypted_env, tenant_id, created_at, updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
g.ID, g.BinaryID, g.AgentID,
|
|
nullableJSONRaw(g.DenyArgs), nullableJSONRaw(g.DenyVerbose),
|
|
g.TimeoutSeconds, g.Tips,
|
|
g.Enabled, nilIfEmptyBytes(g.EncryptedEnv), tenantID, nowStr, nowStr,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteSecureCLIAgentGrantStore) Get(ctx context.Context, id uuid.UUID) (*store.SecureCLIAgentGrant, error) {
|
|
query := `SELECT ` + grantSelectCols + ` FROM secure_cli_agent_grants WHERE id = ?`
|
|
args := []any{id}
|
|
if !store.IsCrossTenant(ctx) {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
query += ` AND tenant_id = ?`
|
|
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 *SQLiteSecureCLIAgentGrantStore) 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().UTC().Format(time.RFC3339Nano)
|
|
|
|
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 *SQLiteSecureCLIAgentGrantStore) 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 = ?", 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 = ? AND tenant_id = ?", id, tid)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteSecureCLIAgentGrantStore) ListByBinary(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
|
|
query := `SELECT ` + grantSelectCols + ` FROM secure_cli_agent_grants WHERE binary_id = ?`
|
|
args := []any{binaryID}
|
|
if !store.IsCrossTenant(ctx) {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
return nil, nil
|
|
}
|
|
query += ` AND tenant_id = ?`
|
|
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 *SQLiteSecureCLIAgentGrantStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
|
|
query := `SELECT ` + grantSelectCols + ` FROM secure_cli_agent_grants WHERE agent_id = ?`
|
|
args := []any{agentID}
|
|
if !store.IsCrossTenant(ctx) {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
return nil, nil
|
|
}
|
|
query += ` AND tenant_id = ?`
|
|
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 *SQLiteSecureCLIAgentGrantStore) scanRow(row *sql.Row) (*store.SecureCLIAgentGrant, error) {
|
|
var g store.SecureCLIAgentGrant
|
|
var denyArgs, denyVerbose []byte
|
|
var timeout *int
|
|
var tips *string
|
|
var encEnv []byte
|
|
var createdAt, updatedAt sqliteTime
|
|
|
|
err := row.Scan(
|
|
&g.ID, &g.BinaryID, &g.AgentID,
|
|
&denyArgs, &denyVerbose, &timeout, &tips,
|
|
&g.Enabled, &encEnv, &createdAt, &updatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyGrantNullable(&g, denyArgs, denyVerbose, timeout, tips)
|
|
g.CreatedAt = createdAt.Time
|
|
g.UpdatedAt = updatedAt.Time
|
|
if err := s.decryptGrantEnv(&g, encEnv); err != nil {
|
|
return nil, err
|
|
}
|
|
return &g, nil
|
|
}
|
|
|
|
func (s *SQLiteSecureCLIAgentGrantStore) 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
|
|
var encEnv []byte
|
|
var createdAt, updatedAt sqliteTime
|
|
|
|
if err := rows.Scan(
|
|
&g.ID, &g.BinaryID, &g.AgentID,
|
|
&denyArgs, &denyVerbose, &timeout, &tips,
|
|
&g.Enabled, &encEnv, &createdAt, &updatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan secure_cli_agent_grants row: %w", err)
|
|
}
|
|
applyGrantNullable(&g, denyArgs, denyVerbose, timeout, tips)
|
|
g.CreatedAt = createdAt.Time
|
|
g.UpdatedAt = updatedAt.Time
|
|
// Finding #4: Log decrypt failures instead of silently masking them.
|
|
// Consistent with PG implementation — error is logged but row is still returned.
|
|
if err := s.decryptGrantEnv(&g, encEnv); err != nil {
|
|
slog.Error("security.grant.decrypt_failed",
|
|
"grant_id", g.ID,
|
|
"binary_id", g.BinaryID,
|
|
"err", err,
|
|
)
|
|
}
|
|
result = append(result, g)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// applyGrantNullable converts scanned nullable values to pointer fields on the grant struct.
|
|
func applyGrantNullable(g *store.SecureCLIAgentGrant, denyArgs, denyVerbose []byte, timeout *int, tips *string) {
|
|
if len(denyArgs) > 0 {
|
|
raw := json.RawMessage(denyArgs)
|
|
g.DenyArgs = &raw
|
|
}
|
|
if len(denyVerbose) > 0 {
|
|
raw := json.RawMessage(denyVerbose)
|
|
g.DenyVerbose = &raw
|
|
}
|
|
g.TimeoutSeconds = timeout
|
|
g.Tips = tips
|
|
}
|
|
|
|
// decryptGrantEnv decrypts stored encrypted_env bytes into g.EncryptedEnv.
|
|
// Returns error if encKey is set but decryption fails (fail-closed).
|
|
func (s *SQLiteSecureCLIAgentGrantStore) decryptGrantEnv(g *store.SecureCLIAgentGrant, raw []byte) error {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
if s.encKey == "" {
|
|
return fmt.Errorf("encryption key missing: cannot decrypt grant env")
|
|
}
|
|
decrypted, err := crypto.Decrypt(string(raw), s.encKey)
|
|
if err != nil {
|
|
return fmt.Errorf("decrypt grant env: %w", err)
|
|
}
|
|
g.EncryptedEnv = []byte(decrypted)
|
|
return nil
|
|
}
|
|
|
|
// UpdateGrantEnv encrypts plaintextEnv and persists it on the grant row.
|
|
// Pass nil to clear the env override. Fails closed if encKey is missing and plaintextEnv is non-empty.
|
|
func (s *SQLiteSecureCLIAgentGrantStore) UpdateGrantEnv(ctx context.Context, grantID uuid.UUID, plaintextEnv []byte) error {
|
|
var envBytes []byte
|
|
if len(plaintextEnv) > 0 {
|
|
if s.encKey == "" {
|
|
return fmt.Errorf("encryption key missing: cannot persist grant env")
|
|
}
|
|
enc, err := crypto.Encrypt(string(plaintextEnv), s.encKey)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt grant env: %w", err)
|
|
}
|
|
envBytes = []byte(enc)
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
if store.IsCrossTenant(ctx) {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE secure_cli_agent_grants SET encrypted_env = ?, updated_at = ? WHERE id = ?`,
|
|
nilIfEmptyBytes(envBytes), now, grantID,
|
|
)
|
|
return err
|
|
}
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
return fmt.Errorf("tenant_id required")
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE secure_cli_agent_grants SET encrypted_env = ?, updated_at = ? WHERE id = ? AND tenant_id = ?`,
|
|
nilIfEmptyBytes(envBytes), now, grantID, tid,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// nullableJSONRaw returns nil if the pointer is nil, otherwise the raw bytes.
|
|
func nullableJSONRaw(v *json.RawMessage) any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
return []byte(*v)
|
|
}
|
|
|
|
// nilIfEmptyBytes returns nil if the slice is empty, otherwise the slice (for nullable BLOB columns).
|
|
func nilIfEmptyBytes(b []byte) any {
|
|
if len(b) == 0 {
|
|
return nil
|
|
}
|
|
return b
|
|
}
|