Files
goclaw/internal/tools/credentialed_exec_env_test.go

297 lines
9.7 KiB
Go

package tools
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestMergeCredentialedEnvPerUserOverridesGrantEnv(t *testing.T) {
binary := &store.SecureCLIBinary{
EncryptedEnv: []byte(`{"SHARED_KEY":"binary","BINARY_ONLY":"base"}`),
}
binary.MergeGrantOverrides(&store.SecureCLIAgentGrant{
EncryptedEnv: []byte(`{"SHARED_KEY":"grant","GRANT_ONLY":"agent"}`),
})
binary.UserEnv = []byte(`{"SHARED_KEY":"user","USER_ONLY":"personal"}`)
env, err := mergeCredentialedEnv(binary)
if err != nil {
t.Fatalf("mergeCredentialedEnv returned error: %v", err)
}
if got := env["SHARED_KEY"]; got != "user" {
t.Fatalf("expected per-user env to win for duplicate key, got %q", got)
}
if got := env["GRANT_ONLY"]; got != "agent" {
t.Fatalf("expected grant env key to remain, got %q", got)
}
if got := env["USER_ONLY"]; got != "personal" {
t.Fatalf("expected per-user env key to remain, got %q", got)
}
if _, ok := env["BINARY_ONLY"]; ok {
t.Fatal("expected agent grant env to replace binary default env")
}
}
func TestMergeCredentialedEnvFailsClosedOnInvalidUserEnv(t *testing.T) {
_, err := mergeCredentialedEnv(&store.SecureCLIBinary{
EncryptedEnv: []byte(`{"SHARED_KEY":"grant"}`),
UserEnv: []byte(`{broken json`),
})
if err == nil {
t.Fatal("expected invalid per-user env JSON to fail closed")
}
}
func TestMergeCredentialedEnvFlattensSensitiveValueEntries(t *testing.T) {
binary := &store.SecureCLIBinary{
EncryptedEnv: []byte(`{
"TOKEN":{"kind":"sensitive","value":"secret"},
"PUBLIC_BASE_URL":{"kind":"value","value":"https://goclaw.sh"}
}`),
UserEnv: []byte(`{"PUBLIC_BASE_URL":{"kind":"value","value":"https://user.example"}}`),
}
env, err := mergeCredentialedEnv(binary)
if err != nil {
t.Fatalf("mergeCredentialedEnv() error = %v", err)
}
if env["TOKEN"] != "secret" {
t.Fatalf("TOKEN = %q", env["TOKEN"])
}
if env["PUBLIC_BASE_URL"] != "https://user.example" {
t.Fatalf("PUBLIC_BASE_URL = %q", env["PUBLIC_BASE_URL"])
}
}
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{
BinaryName: "rapidapi",
EncryptedEnv: []byte("{}"),
TimeoutSeconds: 10,
DenyArgs: json.RawMessage("[]"),
DenyVerbose: json.RawMessage("[]"),
}
tool := NewExecTool(t.TempDir(), false)
tool.SetSecureCLIStore(stub)
ctx := store.WithTenantID(store.WithAgentID(context.Background(), uuid.New()), uuid.New())
ctx = store.WithCredentialUserID(ctx, "tenant-user-rapidapi")
result := tool.Execute(ctx, map[string]any{"command": "rapidapi search weather"})
if !result.IsError {
t.Fatalf("expected missing RAPIDAPI_KEY to fail")
}
if !strings.Contains(result.ForLLM, "missing required credential env") {
t.Fatalf("expected actionable missing-env error, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "RAPIDAPI_KEY") {
t.Fatalf("expected missing key name in error, got: %s", result.ForLLM)
}
if strings.Contains(result.ForLLM, "not found in PATH") || strings.Contains(result.ForLLM, "Binary resolution failed") {
t.Fatalf("missing env should be reported before binary resolution, got: %s", result.ForLLM)
}
if strings.Contains(result.ForLLM, "tenant-user-rapidapi") {
t.Fatalf("credential user id leaked into user-facing error: %s", result.ForLLM)
}
}
func TestExec_RapidAPIWithRequiredEnvReachesDirectExec(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell script fixture is POSIX-only")
}
binDir := t.TempDir()
binPath := filepath.Join(binDir, "rapidapi")
if err := os.WriteFile(binPath, []byte("#!/bin/sh\n[ -n \"$RAPIDAPI_KEY\" ] || exit 43\nprintf 'rapidapi args:%s\\n' \"$*\"\n"), 0o755); err != nil {
t.Fatal(err)
}
stub := newStubSecureCLIStore()
stub.byName["rapidapi"] = &store.SecureCLIBinary{
BinaryName: "rapidapi",
BinaryPath: &binPath,
EncryptedEnv: []byte("{}"),
UserEnv: []byte(`{"RAPIDAPI_KEY":"test-key-not-real"}`),
TimeoutSeconds: 10,
DenyArgs: json.RawMessage("[]"),
DenyVerbose: json.RawMessage("[]"),
}
tool := NewExecTool(t.TempDir(), false)
tool.SetSecureCLIStore(stub)
ctx := store.WithTenantID(store.WithAgentID(context.Background(), uuid.New()), uuid.New())
result := tool.Execute(ctx, map[string]any{"command": "rapidapi search weather"})
if result.IsError {
t.Fatalf("expected rapidapi direct exec to run, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "rapidapi args:search weather") {
t.Fatalf("expected script output, got: %s", result.ForLLM)
}
if strings.Contains(result.ForLLM, "test-key-not-real") {
t.Fatalf("credential value leaked into output: %s", result.ForLLM)
}
}
func TestExec_GoClawGatewayTokenRawOutputIsScrubbed(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell script fixture is POSIX-only")
}
const token = "plain-gateway-token-SHOULD-NOT-LEAK-12345"
binDir := t.TempDir()
binPath := filepath.Join(binDir, "goclaw")
if err := os.WriteFile(binPath, []byte("#!/bin/sh\nprintf '%s\\n' \"$GOCLAW_GATEWAY_TOKEN\"\n"), 0o755); err != nil {
t.Fatal(err)
}
stub := newStubSecureCLIStore()
stub.byName["goclaw"] = &store.SecureCLIBinary{
BinaryName: "goclaw",
BinaryPath: &binPath,
EncryptedEnv: []byte(`{
"GOCLAW_GATEWAY_TOKEN":{"kind":"sensitive","value":"` + token + `"},
"GOCLAW_SERVER":{"kind":"sensitive","value":"http://127.0.0.1:18790"}
}`),
TimeoutSeconds: 10,
DenyArgs: json.RawMessage("[]"),
DenyVerbose: json.RawMessage("[]"),
}
tool := NewExecTool(t.TempDir(), false)
tool.SetSecureCLIStore(stub)
ctx := store.WithTenantID(store.WithAgentID(context.Background(), uuid.New()), uuid.New())
result := tool.Execute(ctx, map[string]any{"command": "goclaw agent list"})
if result.IsError {
t.Fatalf("expected goclaw direct exec to run, got: %s", result.ForLLM)
}
if strings.Contains(result.ForLLM, token) {
t.Fatalf("raw gateway token leaked into output: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "[REDACTED]") {
t.Fatalf("expected gateway token to be redacted, got: %s", result.ForLLM)
}
}
func TestExec_GHMissingRequiredEnvFailsBeforeRawAuth(t *testing.T) {
stub := newStubSecureCLIStore()
stub.byName["gh"] = &store.SecureCLIBinary{
BinaryName: "gh",
EncryptedEnv: []byte("{}"),
TimeoutSeconds: 10,
DenyArgs: json.RawMessage("[]"),
DenyVerbose: json.RawMessage("[]"),
}
tool := NewExecTool(t.TempDir(), false)
tool.SetSecureCLIStore(stub)
ctx := store.WithTenantID(store.WithAgentID(context.Background(), uuid.New()), uuid.New())
result := tool.Execute(ctx, map[string]any{"command": "gh issue list --repo digitopvn/goclaw --limit 1"})
if !result.IsError {
t.Fatalf("expected missing GH_TOKEN to fail")
}
if !strings.Contains(result.ForLLM, "missing required credential env") {
t.Fatalf("expected actionable missing-env error, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "GH_TOKEN") {
t.Fatalf("expected GH_TOKEN in error, got: %s", result.ForLLM)
}
if strings.Contains(result.ForLLM, "gh auth login") {
t.Fatalf("raw gh auth guidance leaked through: %s", result.ForLLM)
}
}
func TestExec_GitRemoteCommandWithoutTypedCredentialFailsBeforeRawAuth(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell script fixture is POSIX-only")
}
binDir := t.TempDir()
binPath := filepath.Join(binDir, "git")
if err := os.WriteFile(binPath, []byte("#!/bin/sh\nprintf 'raw git reached\\n' >&2\nexit 128\n"), 0o755); err != nil {
t.Fatal(err)
}
adapterName := "git"
stub := newStubSecureCLIStore()
stub.byName["git"] = &store.SecureCLIBinary{
BinaryName: "git",
BinaryPath: &binPath,
EncryptedEnv: []byte("{}"),
TimeoutSeconds: 10,
DenyArgs: json.RawMessage("[]"),
DenyVerbose: json.RawMessage("[]"),
AdapterName: &adapterName,
}
tool := NewExecTool(t.TempDir(), false)
tool.SetSecureCLIStore(stub)
ctx := store.WithTenantID(store.WithAgentID(context.Background(), uuid.New()), uuid.New())
result := tool.Execute(ctx, map[string]any{"command": "git push -u origin feature"})
if !result.IsError {
t.Fatalf("expected missing typed git credential to fail")
}
if !strings.Contains(result.ForLLM, "Git credential resolution failed") {
t.Fatalf("expected git credential diagnostic, got: %s", result.ForLLM)
}
if strings.Contains(result.ForLLM, "raw git reached") {
t.Fatalf("raw git command executed before credential resolution failed: %s", result.ForLLM)
}
}