Files
goclaw/internal/tools/shell_credentialed_gate_test.go
T
Duy /zuey/andGitHub ce5ea10d46 fix(runtime): repair package installs, archives, logs, and telegram startup
Merge runtime/package install fixes, archive attachment handling, operator log access, and Telegram startup IPv4 fallback.
2026-05-20 21:26:10 +07:00

504 lines
18 KiB
Go

package tools
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// stubSecureCLIStore is a minimal in-memory SecureCLIStore used by shell gate
// unit tests. Only LookupByBinary (Phase 1) and IsRegisteredBinary (Phase 2)
// have meaningful logic; the rest return zero values to satisfy the interface.
type stubSecureCLIStore struct {
mu sync.Mutex
byName map[string]*store.SecureCLIBinary
registered map[string]bool
lookupCalls int
isRegisteredErr error
// isRegisteredSleep makes IsRegisteredBinary block for this duration to
// exercise the 2s fail-closed timeout in Phase 3.
isRegisteredSleep time.Duration
// lastLookupName captures the exact name passed to LookupByBinary so
// tests can assert normalization happened before the call.
lastLookupName string
}
func newStubSecureCLIStore() *stubSecureCLIStore {
return &stubSecureCLIStore{
byName: map[string]*store.SecureCLIBinary{},
registered: map[string]bool{},
}
}
// --- Meaningful methods ---
func (s *stubSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID, userID string) (*store.SecureCLIBinary, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.lookupCalls++
s.lastLookupName = binaryName
return s.byName[binaryName], nil
}
// IsRegisteredBinary is exercised by Phase 3 tests. Keeping the stub
// implementation here keeps the interface satisfied from Phase 2 onwards.
func (s *stubSecureCLIStore) IsRegisteredBinary(ctx context.Context, binaryName string) (bool, error) {
if s.isRegisteredSleep > 0 {
select {
case <-time.After(s.isRegisteredSleep):
case <-ctx.Done():
return false, ctx.Err()
}
}
s.mu.Lock()
defer s.mu.Unlock()
if s.isRegisteredErr != nil {
return false, s.isRegisteredErr
}
return s.registered[binaryName], nil
}
// --- Zero-value stubs for interface satisfaction ---
func (s *stubSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary) error {
return nil
}
func (s *stubSecureCLIStore) Get(ctx context.Context, id uuid.UUID) (*store.SecureCLIBinary, error) {
return nil, nil
}
func (s *stubSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
return nil
}
func (s *stubSecureCLIStore) Delete(ctx context.Context, id uuid.UUID) error { return nil }
func (s *stubSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, error) {
return nil, nil
}
func (s *stubSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]store.SecureCLIBinary, 0, len(s.byName))
for _, b := range s.byName {
if b != nil {
out = append(out, *b)
}
}
return out, nil
}
func (s *stubSecureCLIStore) ListForAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) {
return nil, nil
}
func (s *stubSecureCLIStore) GetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) (*store.SecureCLIUserCredential, error) {
return nil, nil
}
func (s *stubSecureCLIStore) SetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte) error {
return nil
}
func (s *stubSecureCLIStore) DeleteUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) error {
return nil
}
func (s *stubSecureCLIStore) ListUserCredentials(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIUserCredential, error) {
return nil, nil
}
// --- Phase 1 characterization tests ---
// TestExec_NoStoreWired_FallsThrough confirms that when secureCLIStore is nil
// (Lite edition / missing encryption key), Execute falls through to host exec.
func TestExec_NoStoreWired_FallsThrough(t *testing.T) {
tool := NewExecTool(t.TempDir(), false)
result := tool.Execute(context.Background(), map[string]any{"command": "echo hello"})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "hello") {
t.Fatalf("expected output to contain %q, got %q", "hello", result.ForLLM)
}
}
// TestExec_UnregisteredBinary_FallsThrough: stub wired but zero entries —
// unregistered binary should fall through to host exec unchanged.
func TestExec_UnregisteredBinary_FallsThrough(t *testing.T) {
stub := newStubSecureCLIStore()
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": "echo hello"})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "hello") {
t.Fatalf("expected output to contain %q, got %q", "hello", result.ForLLM)
}
stub.mu.Lock()
calls := stub.lookupCalls
stub.mu.Unlock()
if calls != 1 {
t.Fatalf("expected lookupCalls == 1 (gate consulted), got %d", calls)
}
}
// TestExec_GrantedBinary_UsesCredentialedPath — Red Team F9 applied:
// sentinel binary name (not echo) proves the credentialed branch was taken.
func TestExec_GrantedBinary_UsesCredentialedPath(t *testing.T) {
const sentinel = "xyz_goclaw_sentinel"
stub := newStubSecureCLIStore()
stub.byName[sentinel] = &store.SecureCLIBinary{
BinaryName: sentinel,
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": sentinel + " --help"})
stub.mu.Lock()
calls := stub.lookupCalls
stub.mu.Unlock()
if calls != 1 {
t.Fatalf("expected lookupCalls == 1 (gate consulted store), got %d", calls)
}
if !result.IsError {
t.Fatalf("expected IsError=true (credentialed branch entered but binary not on PATH), got %+v", result)
}
// Soft sanity: the error must NOT come from sh fall-through. A fall-through
// error would typically contain "sh: 1:" or the shell path — proving we
// took a different code path than intended.
if strings.Contains(result.ForLLM, "sh: 1:") || strings.Contains(result.ForLLM, "/bin/sh") {
t.Fatalf("expected credentialed-path error, got shell fall-through error: %s", result.ForLLM)
}
}
func TestExec_CredentialedArgsDoNotTriggerPackageInstallApproval(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell script fixture is POSIX-only")
}
tool, ctx := newCredentialedZernioTestTool(t)
result := tool.Execute(ctx, map[string]any{
"command": `zernio posts:create --accounts abc --text "install with npm install -g agent-browser"`,
})
if result.IsError {
t.Fatalf("credentialed command should treat quoted package install text as argv data, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "posts:create") || !strings.Contains(result.ForLLM, "npm install -g agent-browser") {
t.Fatalf("expected script to receive original args, got: %s", result.ForLLM)
}
}
func TestExec_CredentialedRejectsUnquotedShellOperator(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell script fixture is POSIX-only")
}
tool, ctx := newCredentialedZernioTestTool(t)
result := tool.Execute(ctx, map[string]any{
"command": `zernio posts:create --text ok ; echo leaked`,
})
if !result.IsError {
t.Fatalf("expected credentialed command with unquoted shell operator to fail")
}
if !strings.Contains(result.ForLLM, "Shell operators not supported") && !strings.Contains(result.ForLLM, "Detected: ;") {
t.Fatalf("expected shell-operator rejection, got: %s", result.ForLLM)
}
}
func TestExec_NonCredentialedPackageInstallStillDenied(t *testing.T) {
tool := NewExecTool(t.TempDir(), false)
result := tool.Execute(context.Background(), map[string]any{
"command": "npm install left-pad",
})
if !result.IsError {
t.Fatalf("expected package install command to be denied")
}
if !strings.Contains(result.ForLLM, "package_install") && !strings.Contains(result.ForLLM, "matches pattern") {
t.Fatalf("expected package-install policy denial, got: %s", result.ForLLM)
}
}
func TestPosixShellPathPrefersAbsoluteShell(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX-only")
}
got, err := posixShellPath()
if err != nil {
t.Fatalf("expected absolute POSIX shell on test runtime: %v", err)
}
if got != "/bin/sh" && got != "/usr/bin/sh" {
t.Fatalf("unexpected shell path: %q", got)
}
if _, err := os.Stat(got); err != nil {
t.Fatalf("shell path %q must exist: %v", got, err)
}
}
func newCredentialedZernioTestTool(t *testing.T) (*ExecTool, context.Context) {
t.Helper()
binDir := t.TempDir()
binPath := filepath.Join(binDir, "zernio")
if err := os.WriteFile(binPath, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
stub := newStubSecureCLIStore()
stub.byName["zernio"] = &store.SecureCLIBinary{
BinaryName: "zernio",
BinaryPath: &binPath,
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())
return tool, ctx
}
// --- Phase 3 gate-enforcement tests ---
// helper: build a gate-wired ExecTool with a fresh stub + ctx.
func newGateTestTool(t *testing.T) (*ExecTool, *stubSecureCLIStore, context.Context) {
t.Helper()
stub := newStubSecureCLIStore()
tool := NewExecTool(t.TempDir(), false)
tool.SetSecureCLIStore(stub)
ctx := store.WithTenantID(store.WithAgentID(context.Background(), uuid.New()), uuid.New())
return tool, stub, ctx
}
// TestExec_BlocksRegisteredBinaryWhenNoGrant: registered without grant → deny.
func TestExec_BlocksRegisteredBinaryWhenNoGrant(t *testing.T) {
tool, stub, ctx := newGateTestTool(t)
stub.registered["gh"] = true
result := tool.Execute(ctx, map[string]any{"command": "gh auth status"})
if !result.IsError {
t.Fatalf("expected deny, got success: %+v", result)
}
if !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
t.Fatalf("expected grant-required message, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "gh") {
t.Fatalf("expected binary name in message, got: %s", result.ForLLM)
}
}
// TestExec_AllowsUnregisteredBinary: gate passes through when no match.
func TestExec_AllowsUnregisteredBinary(t *testing.T) {
tool, _, ctx := newGateTestTool(t)
result := tool.Execute(ctx, map[string]any{"command": "echo hello"})
if result.IsError {
t.Fatalf("expected pass-through, got error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "hello") {
t.Fatalf("expected echo output, got: %s", result.ForLLM)
}
}
// TestExec_BinaryNameNormalization (Red Team F5/F8): case + path variants all match.
func TestExec_BinaryNameNormalization(t *testing.T) {
cases := []string{
"gh version",
"/usr/bin/gh version",
"./gh version",
"GH version",
"Gh version",
" gh version ",
}
for _, cmd := range cases {
t.Run(cmd, func(t *testing.T) {
tool, stub, ctx := newGateTestTool(t)
stub.registered["gh"] = true
result := tool.Execute(ctx, map[string]any{"command": cmd})
if !result.IsError {
t.Fatalf("expected deny for %q, got success: %+v", cmd, result)
}
if !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
t.Fatalf("expected grant-required message for %q, got: %s", cmd, result.ForLLM)
}
})
}
}
// Shell-wrapper denials (Red Team F1).
func TestExec_BlocksShellWrapper_ShDashC(t *testing.T) {
tool, stub, ctx := newGateTestTool(t)
stub.registered["gh"] = true
result := tool.Execute(ctx, map[string]any{"command": "sh -c 'gh auth status'"})
if !result.IsError || !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
t.Fatalf("expected deny through sh -c wrapper, got: %+v", result)
}
}
func TestExec_BlocksShellWrapper_BashDashC(t *testing.T) {
tool, stub, ctx := newGateTestTool(t)
stub.registered["gh"] = true
result := tool.Execute(ctx, map[string]any{"command": `bash -c "gh api repos/x/y"`})
if !result.IsError || !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
t.Fatalf("expected deny through bash -c wrapper, got: %+v", result)
}
}
func TestExec_BlocksShellWrapper_SlashBinSh(t *testing.T) {
tool, stub, ctx := newGateTestTool(t)
stub.registered["gh"] = true
result := tool.Execute(ctx, map[string]any{"command": "/bin/sh -c 'gh auth status'"})
if !result.IsError || !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
t.Fatalf("expected deny through /bin/sh -c, got: %+v", result)
}
}
func TestExec_BlocksEnvWrapper(t *testing.T) {
tool, stub, ctx := newGateTestTool(t)
stub.registered["gh"] = true
result := tool.Execute(ctx, map[string]any{"command": "env GH_TOKEN=x gh auth status"})
if !result.IsError || !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
t.Fatalf("expected deny through env wrapper, got: %+v", result)
}
}
func TestExec_BlocksEnvUsrBinEnvSh(t *testing.T) {
tool, stub, ctx := newGateTestTool(t)
stub.registered["gh"] = true
result := tool.Execute(ctx, map[string]any{"command": "/usr/bin/env sh -c 'gh api'"})
if !result.IsError || !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
t.Fatalf("expected deny through env→sh wrapper, got: %+v", result)
}
}
func TestExec_BlocksNestedWrapper_Depth3(t *testing.T) {
tool, stub, ctx := newGateTestTool(t)
stub.registered["gh"] = true
// bash -c "sh -c 'env GH_TOKEN=x gh auth'"
cmd := `bash -c "sh -c 'env GH_TOKEN=x gh auth'"`
result := tool.Execute(ctx, map[string]any{"command": cmd})
if !result.IsError || !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
t.Fatalf("expected deny through depth-3 wrapper, got: %+v", result)
}
}
// TestExec_RejectsWrapperDepthCap (Validation Session 1): depth 4+ → unconditional deny.
func TestExec_RejectsWrapperDepthCap(t *testing.T) {
tool, _, ctx := newGateTestTool(t)
// 4-level nesting: bash -c 'sh -c "bash -c \"sh -c gh\"..."'
cmd := `bash -c "sh -c 'bash -c \"sh -c gh\"'"`
result := tool.Execute(ctx, map[string]any{"command": cmd})
if !result.IsError {
t.Fatalf("expected deny for depth-4+ nesting, got: %+v", result)
}
if !strings.Contains(result.ForLLM, "Command nesting too deep") {
t.Fatalf("expected nesting-too-deep message, got: %s", result.ForLLM)
}
}
func TestExec_AllowsShellWrapperWithUnregisteredInner(t *testing.T) {
tool, _, ctx := newGateTestTool(t)
// registered empty, inner is echo (not registered) → fall-through.
command := "sh -c 'echo hi'"
if runtime.GOOS == "windows" {
command = "cmd /c echo hi"
}
result := tool.Execute(ctx, map[string]any{"command": command})
if result.IsError {
t.Fatalf("expected pass-through when inner unregistered, got error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "hi") {
t.Fatalf("expected echo output, got: %s", result.ForLLM)
}
}
// Normalization consistency at LookupByBinary (Red Team F5).
func TestExec_LookupPathNormalizes(t *testing.T) {
const sentinel = "xyz_goclaw_sentinel"
tool, stub, ctx := newGateTestTool(t)
stub.byName[sentinel] = &store.SecureCLIBinary{
BinaryName: sentinel,
EncryptedEnv: []byte("{}"),
TimeoutSeconds: 10,
DenyArgs: json.RawMessage("[]"),
DenyVerbose: json.RawMessage("[]"),
}
// Provide path-prefixed, mixed-case, padded form.
_ = tool.Execute(ctx, map[string]any{"command": " /usr/local/bin/XYZ_GOCLAW_SENTINEL --help "})
stub.mu.Lock()
got := stub.lastLookupName
stub.mu.Unlock()
if got != sentinel {
t.Fatalf("expected LookupByBinary called with normalized %q, got %q", sentinel, got)
}
}
// Fail-CLOSED on gate DB error (Red Team F7).
func TestExec_GateDbError_FailsClosed(t *testing.T) {
tool, stub, ctx := newGateTestTool(t)
stub.isRegisteredErr = errors.New("simulated db outage")
result := tool.Execute(ctx, map[string]any{"command": "echo hello"})
if !result.IsError {
t.Fatalf("expected fail-closed, got success: %+v", result)
}
if !strings.Contains(result.ForLLM, "Secure CLI gate temporarily unavailable") {
t.Fatalf("expected gate-unavailable message, got: %s", result.ForLLM)
}
if strings.Contains(result.ForLLM, "hello") {
t.Fatalf("command must NOT execute on gate error, got: %s", result.ForLLM)
}
}
// Fail-CLOSED on gate DB timeout (Red Team F7).
func TestExec_GateDbTimeout_FailsClosed(t *testing.T) {
tool, stub, ctx := newGateTestTool(t)
stub.isRegisteredSleep = 3 * time.Second
start := time.Now()
result := tool.Execute(ctx, map[string]any{"command": "echo hello"})
elapsed := time.Since(start)
if !result.IsError {
t.Fatalf("expected fail-closed on timeout, got success: %+v", result)
}
if !strings.Contains(result.ForLLM, "Secure CLI gate temporarily unavailable") {
t.Fatalf("expected gate-unavailable message, got: %s", result.ForLLM)
}
// 2s context timeout + some slack. Must not wait full 3s sleep.
if elapsed > 2500*time.Millisecond {
t.Fatalf("expected gate timeout ≤2.5s, took %s", elapsed)
}
}
// Env scrub in fall-through exec (Red Team F4): GH_TOKEN must not leak into child.
func TestExec_FallThrough_ScrubsGHToken(t *testing.T) {
tool, _, ctx := newGateTestTool(t)
t.Setenv("GH_TOKEN", "supersecretvalue")
// Use single-quote printf so shell sees the literal; our gate lets "sh"
// fall through (sh is not registered, echo is not registered).
command := `sh -c 'echo "token=$GH_TOKEN"'`
if runtime.GOOS == "windows" {
command = `cmd /c echo token=%GH_TOKEN%`
}
result := tool.Execute(ctx, map[string]any{"command": command})
if result.IsError {
t.Fatalf("expected pass-through, got: %s", result.ForLLM)
}
if strings.Contains(result.ForLLM, "supersecretvalue") {
t.Fatalf("GH_TOKEN leaked to child process: %s", result.ForLLM)
}
}