mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 04:20:16 +00:00
Closes credential-scope bypass where an agent without a `secure_cli_agent_grants` row could still invoke a registered binary via shell fallthrough and pick up inherited env (`$GH_TOKEN`) or on-disk OAuth state. Registration is now an authorization boundary, not only a credential-injection hint. - Add `SecureCLIStore.IsRegisteredBinary` on PG + SQLite backends — case-insensitive, tenant-scoped. - New gate branch in `internal/tools/shell.go` (after normalization, before exec approval) with shell-wrapper unwrap depth 3 (sh/bash/zsh/dash -c, env K=V, nohup, stdbuf, timeout), 2s DB-lookup timeout, fail-CLOSED on error. New log events: `security.credentialed_binary_denied`, `security.credentialed_binary_gate_error`, `security.credentialed_binary_wrapper_too_deep`. - Env scrubbing on host fall-through (`internal/tools/env_scrub.go`) strips static credential keys (GH_TOKEN, AWS_*, OPENAI_API_KEY, …) plus dynamic keys from tenant's registered binaries; preserves HOME/PATH/TERM/LANG/USER/TZ. - Subagent ExecTool registration (`cmd/gateway_agents.go` → `buildSubagentToolsRegistry`) now receives the same `SecureCLIStore` — parent can't delegate to a child to bypass the gate. - Binary names lowercased on Create/Update for symmetry with case-insensitive lookup. - Thread `pgStores.SecureCLI` into `setupSubagents` in `cmd/gateway.go`. - 25+ unit tests (gate, env-scrub, SQLite IsRegisteredBinary) + 5 PG integration tests (deny-ungranted, allow-granted, unregistered-unchanged, is_global-not-denied regression, shell-wrapper-bypass denied). All green.
230 lines
7.0 KiB
Go
230 lines
7.0 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
)
|
|
|
|
// gateTestBinaryName is deliberately NOT a real binary on PATH so the
|
|
// "allowed" path can never accidentally exec something real.
|
|
const gateTestBinaryName = "goclaw_test_cli"
|
|
|
|
// gateFixture holds the common seeds for gate enforcement tests.
|
|
type gateFixture struct {
|
|
db *sql.DB
|
|
tool *tools.ExecTool
|
|
tenantID uuid.UUID
|
|
agentA uuid.UUID
|
|
agentB uuid.UUID
|
|
binaryID uuid.UUID
|
|
}
|
|
|
|
// setupGateTest seeds: tenant, two agents under that SAME tenant (Red Team F10),
|
|
// a non-global registered binary, and a grant only for agentA. Returns a wired
|
|
// ExecTool + the IDs. All rows are cleaned up via t.Cleanup.
|
|
func setupGateTest(t *testing.T) *gateFixture {
|
|
t.Helper()
|
|
|
|
db := testDB(t)
|
|
tenantID, agentA := seedTenantAgent(t, db)
|
|
agentB := seedSecondAgent(t, db, tenantID)
|
|
binaryID := seedGateBinary(t, db, tenantID, gateTestBinaryName, false)
|
|
seedGrant(t, db, tenantID, binaryID, agentA)
|
|
|
|
secStore := pg.NewPGSecureCLIStore(db, testEncryptionKey)
|
|
tool := tools.NewExecTool(t.TempDir(), false)
|
|
tool.SetSecureCLIStore(secStore)
|
|
|
|
return &gateFixture{
|
|
db: db,
|
|
tool: tool,
|
|
tenantID: tenantID,
|
|
agentA: agentA,
|
|
agentB: agentB,
|
|
binaryID: binaryID,
|
|
}
|
|
}
|
|
|
|
// seedSecondAgent inserts an additional agent under an existing tenant. Used
|
|
// to satisfy Red Team F10: both test agents must share the same tenant to
|
|
// prove the gate (not tenant isolation) is what denies ungranted exec.
|
|
func seedSecondAgent(t *testing.T, db *sql.DB, tenantID uuid.UUID) uuid.UUID {
|
|
t.Helper()
|
|
|
|
agentID := uuid.New()
|
|
agentKey := "test-" + agentID.String()[:8]
|
|
|
|
_, err := db.Exec(
|
|
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
|
|
VALUES ($1, $2, $3, 'predefined', 'active', 'test', 'test-model', 'test-owner')`,
|
|
agentID, tenantID, agentKey)
|
|
if err != nil {
|
|
t.Fatalf("seed second agent: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
db.Exec("DELETE FROM agents WHERE id = $1", agentID)
|
|
})
|
|
|
|
return agentID
|
|
}
|
|
|
|
// seedGateBinary inserts a secure_cli_binaries row with a caller-specified
|
|
// binary_name and is_global flag. Matches shape used by seedSecureCLI.
|
|
func seedGateBinary(t *testing.T, db *sql.DB, tenantID uuid.UUID, name string, isGlobal bool) uuid.UUID {
|
|
t.Helper()
|
|
|
|
binaryID := uuid.New()
|
|
_, err := db.Exec(
|
|
`INSERT INTO secure_cli_binaries (id, tenant_id, binary_name, encrypted_env, description, enabled, is_global)
|
|
VALUES ($1, $2, $3, $4, 'gate test CLI', true, $5)`,
|
|
binaryID, tenantID, name, []byte(`{}`), isGlobal)
|
|
if err != nil {
|
|
t.Fatalf("seed gate binary: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
db.Exec("DELETE FROM secure_cli_agent_grants WHERE binary_id = $1", binaryID)
|
|
db.Exec("DELETE FROM secure_cli_user_credentials WHERE binary_id = $1", binaryID)
|
|
db.Exec("DELETE FROM secure_cli_binaries WHERE id = $1", binaryID)
|
|
})
|
|
|
|
return binaryID
|
|
}
|
|
|
|
// seedGrant inserts a secure_cli_agent_grants row tying an agent to a binary.
|
|
func seedGrant(t *testing.T, db *sql.DB, tenantID, binaryID, agentID uuid.UUID) {
|
|
t.Helper()
|
|
|
|
_, err := db.Exec(
|
|
`INSERT INTO secure_cli_agent_grants (binary_id, agent_id, tenant_id, enabled)
|
|
VALUES ($1, $2, $3, true)`,
|
|
binaryID, agentID, tenantID)
|
|
if err != nil {
|
|
t.Fatalf("seed grant: %v", err)
|
|
}
|
|
}
|
|
|
|
// gateCtx builds a ctx with tenant + agent set for gate enforcement.
|
|
func gateCtx(tenantID, agentID uuid.UUID) context.Context {
|
|
ctx := store.WithTenantID(context.Background(), tenantID)
|
|
return store.WithAgentID(ctx, agentID)
|
|
}
|
|
|
|
// TestSecureCLIGate_DeniesUngranted proves the gate denies ungranted agents
|
|
// for a registered, non-global binary (FR2 — the primary fix).
|
|
func TestSecureCLIGate_DeniesUngranted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := setupGateTest(t)
|
|
ctx := gateCtx(f.tenantID, f.agentB)
|
|
|
|
result := f.tool.Execute(ctx, map[string]any{
|
|
"command": gateTestBinaryName + " --help",
|
|
})
|
|
|
|
if !result.IsError {
|
|
t.Fatalf("expected IsError=true for ungranted exec, got: %+v", result)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
|
|
t.Fatalf("expected deny message, got: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
// TestSecureCLIGate_AllowsGrantedAgent proves the gate permits a granted
|
|
// agent past the deny branch (FR3). The downstream credentialed exec may
|
|
// still fail because the binary is not on PATH — we assert only that the
|
|
// deny message is NOT returned.
|
|
func TestSecureCLIGate_AllowsGrantedAgent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := setupGateTest(t)
|
|
ctx := gateCtx(f.tenantID, f.agentA)
|
|
|
|
result := f.tool.Execute(ctx, map[string]any{
|
|
"command": gateTestBinaryName + " --help",
|
|
})
|
|
|
|
if strings.Contains(result.ForLLM, "requires a secure CLI grant") {
|
|
t.Fatalf("granted agent unexpectedly denied: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
// TestSecureCLIGate_UnregisteredBinaryUnchanged proves the gate is a no-op
|
|
// for binaries not in the registry (FR4). `echo` must run normally.
|
|
func TestSecureCLIGate_UnregisteredBinaryUnchanged(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
|
|
secStore := pg.NewPGSecureCLIStore(db, testEncryptionKey)
|
|
tool := tools.NewExecTool(t.TempDir(), false)
|
|
tool.SetSecureCLIStore(secStore)
|
|
|
|
ctx := gateCtx(tenantID, agentID)
|
|
result := tool.Execute(ctx, map[string]any{
|
|
"command": "echo hello",
|
|
})
|
|
|
|
if result.IsError {
|
|
t.Fatalf("expected no error for unregistered binary, got: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "hello") {
|
|
t.Fatalf("expected output to contain 'hello', got: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
// TestSecureCLIGate_IsGlobalBinaryNotDenied is Red Team F2 regression guard.
|
|
// A global binary (is_global=true) needs no grant; the gate must NOT deny.
|
|
func TestSecureCLIGate_IsGlobalBinaryNotDenied(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
seedGateBinary(t, db, tenantID, "goclaw_global_test", true)
|
|
|
|
secStore := pg.NewPGSecureCLIStore(db, testEncryptionKey)
|
|
tool := tools.NewExecTool(t.TempDir(), false)
|
|
tool.SetSecureCLIStore(secStore)
|
|
|
|
ctx := gateCtx(tenantID, agentID)
|
|
result := tool.Execute(ctx, map[string]any{
|
|
"command": "goclaw_global_test --help",
|
|
})
|
|
|
|
if strings.Contains(result.ForLLM, "requires a secure CLI grant") {
|
|
t.Fatalf("global binary unexpectedly denied by gate: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
// TestSecureCLIGate_ShellWrapperBypassDenied is Red Team F1 integration guard.
|
|
// Wrapping the registered binary in `sh -c '...'` must still hit the deny path.
|
|
func TestSecureCLIGate_ShellWrapperBypassDenied(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := setupGateTest(t)
|
|
ctx := gateCtx(f.tenantID, f.agentB)
|
|
|
|
result := f.tool.Execute(ctx, map[string]any{
|
|
"command": "sh -c '" + gateTestBinaryName + " --help'",
|
|
})
|
|
|
|
if !result.IsError {
|
|
t.Fatalf("expected IsError=true for wrapped exec, got: %+v", result)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
|
|
t.Fatalf("expected deny message for sh -c wrap, got: %s", result.ForLLM)
|
|
}
|
|
}
|