mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-31 22:22:06 +00:00
Merge runtime/package install fixes, archive attachment handling, operator log access, and Telegram startup IPv4 fallback.
504 lines
18 KiB
Go
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)
|
|
}
|
|
}
|