Files
goclaw/internal/tools/shell_credentialed_gate_test.go
Duy /zuey/andGitHub a591473546 feat(secure-cli): CLI credential adapters framework + git adapter (#82) (#89)
* feat(secure-cli): Phase 1 schema + storage delta (issue #82)

Adds `adapter_name` column to secure_cli_binaries and `credential_type` + `host_scope` columns to secure_cli_user_credentials. LookupByBinary LEFT JOIN now projects AdapterName, UserCredentialType, and UserHostScope. Extends SecureCLIStore with SetUserCredentialsTyped(ctx, binaryID, userID, env, credType, hostScope); legacy SetUserCredentials delegates to typed variant with nil/nil for backward compat.

PG migration 73 + RequiredSchemaVersion bumped to 73. SQLite incremental migrations (versions 39–41) + SchemaVersion 42. 7 SQLite + 6 PG integration tests covering schema, round-trip, NULL legacy, LookupByBinary projection.

Fixes #82

* feat(secure-cli): Phase 2 CredentialAdapter framework + passthrough (issue #82)

Adds CredentialAdapter interface + Injection{ArgvPrefix,Env,Cleanup,ScrubValues} struct. Registry resolves by name; falls back to passthrough for empty/unknown. passthroughAdapter is default no-op — all existing presets (gh/aws/gcloud/kubectl/terraform/gws) behave bit-for-bit identically.

Per-request WithScrubBag(ctx) + AddScrubValuesCtx + ScrubCredentialsCtx for multi-tenant secret isolation (replaces package-global slice). Hook in executeCredentialed between env merge and exec: resolve adapter by bin.AdapterName, reject non-passthrough in sandbox, call Prepare, splice ArgvPrefix, merge Env, defer Cleanup, register ScrubValues.

Audit log security.system_env_injection records adapter name + env key names + argv_prefix_len + sha256(host_scope) — NEVER values. CLIPreset.AdapterName field added; empty default for legacy presets.

12 unit tests covering passthrough no-op, registry fallback + nil safety, Injection shape, hashHostScope determinism, sortedKeys, scrub bag per-request isolation + concurrent + short-value guard.

Fixes #82

* feat(secure-cli): Phase 2b extensibility helpers + psql stub adapter (issue #82)

Proves CredentialAdapter framework generalizes beyond git. Adds materializeEphemeral(ctx, content, prefix) shared helper — 0600 tmpfile + idempotent atomic.Bool cleanup latch; memfd intentionally rejected (resolves "self" against child process).

Adds psqlAdapter consuming framework end-to-end via PGPASSFILE pattern; libpq-spec .pgpass escaping for `:` and `\`. Registers psql preset with AdapterName: "psql" (production UI for typed creds lands in v2).

Interface validation gate passes: Injection shape unchanged, hook is psql-agnostic, no special branch needed.

Tests: ephemeral write/cleanup/concurrent/zero-content; psql routing/content/escaping/error paths/registration.

Fixes #82

* feat(secure-cli): git adapter PAT + SSH implementation (issue #82)

Phase 3 — PAT path
- gitAdapter PAT branch via GIT_CONFIG_COUNT/KEY_0/VALUE_0 env (git 2.31+)
  so the token never lands on argv, .git/config, or remote URL
- Host-scope enforcement with IDN normalization (golang.org/x/net/idna)
  and embedded-userinfo rejection in URL parsing
- CVE-2018-17456 mitigation: resolve remote URLs via `git config --get`
  not `git remote get-url` to dodge ext::sh protocol handler injection
- Case-insensitive DenyArgs blocking `-c http.`, `-c credential.`,
  `-c core.sshcommand`, `config --global/--system`, `credential-helper`,
  bare `daemon`
- New WithExecCwd / ExecCwdFromContext context helpers — fixes a latent
  design gap where the adapter's pre-flight `git config --get` ran in
  goclaw's daemon CWD instead of the agent's repo
- 14 unit tests covering subcommand routing, host normalization,
  scp-form parsing, userinfo rejection, CRLF token rejection,
  CVE-2018-17456 regression, DenyArgs preset coverage
- 3 integration tests against a local TLS git-http-backend server
  proving end-to-end clone + fetch + host-mismatch rejection with
  zero token leakage into the cloned .git/config

Phase 4 — SSH path
- gitAdapter ssh_key branch materializes per-call 0600 tmpfile via
  the Phase 2b materializeEphemeral helper, injects GIT_SSH_COMMAND
  with -o IdentitiesOnly=yes -o BatchMode=yes
  -o StrictHostKeyChecking=accept-new, idempotent cleanup
- ValidateSSHKey using golang.org/x/crypto/ssh rejects
  passphrase-protected keys via ErrSSHKeyPassphraseUnsupported sentinel
- 8 unit tests covering passphrase rejection, env shape, cleanup
  lifecycle, host-mismatch reuse, malformed-blob rejection
- 3 integration tests proving tmpfile 0600 lifecycle, env propagation
  to child process, cleanup-on-exec-failure, no-orphan-on-rejection
- 2 new i18n keys (en/vi/zh) for SSH-passphrase and SSH-key-invalid

Verification: go vet clean, go build ./... clean,
go build -tags sqliteonly ./... clean, go test -race
./internal/tools/ pass, go test -tags integration
./tests/integration/ -run TestGitAdapter pass.

Refs #82

* feat(secure-cli): UI presets + typed credential HTTP path + i18n (issue #82)

Phase 5. Typed PUT envelope with {error:{code,message}, error_key} for
field-level errors. CliCredentialGitFields React component (PAT/SSH picker,
host_scope input, CRLF→LF normalization, masked-edit). 17 i18n keys × 3
locales (backend + frontend). i18n parity test.

* feat(secure-cli): audit log schema + adapter framework docs (issue #82)

Phase 6. emitSystemEnvInjectionAudit helper centralizes
security.system_env_injection slog with host_scope_hash (SHA-256 8 hex,
plaintext hostname omitted for PII safety). Audit shape pinned by
TestEmitSystemEnvInjectionAudit_*. New docs: git-credential-adapter.md
(user guide), credential-adapter-playbook.md (R1 implementer guide w/
kubectl/docker/npm/aws/psql worked mappings). 09-security.md § 14
trust-boundary diagram + SSH TOFU + SIGKILL caveats. 03-tools-system.md
§ 8a. Changelog entry.

* docs(journal): issue #82 CLI credential adapters shipped

Retrospective covering 6 commits across phases 1-6: framework, git PAT/SSH, psql stub, UI/i18n, audit log schema, docs. Notes memfd-drop rationale, TOFU/SIGKILL caveats, sentinel-length lesson from audit-shape tests.

* fix(secure-cli): honor per-request scrub bag on success+failure paths (#82)

Sandbox and host exec paths called the non-Ctx ScrubCredentials, so
adapter ScrubValues registered into the per-request bag during Prepare
(e.g. GitLab glpat-, Bitbucket app-passwords, Azure DevOps PATs, Gitea
tokens, SSH key tmpfile paths) were ignored on stdout/stderr returned
to the agent. Only the package-global regex pass ran — covering ghp_
but nothing else.

Switch all four exec/sandbox call sites to ScrubCredentialsCtx so the
bag is consulted. Also scrub the slog adapter_cleanup_failed line —
os.Remove errors embed the full tmpfile path.

Add 5 regression tests that pin success path, failure path, negative
control (proves the bag is what catches the sentinel), classic-PAT
sanity, and timeout path no-leak.

Locks AC6 against non-GitHub PAT providers.

* fix(secure-cli): address review-pr #89 findings

- ui/web: SSH key Textarea was 'text-xs' on all viewports, triggering
  iOS Safari auto-zoom on focus. Switch to 'text-base md:text-xs' so
  mobile renders 16px (no zoom) while desktop keeps compact mono font.
- psql adapter: add on-disk pgpass tmpfile path to ScrubValues. psql
  echoes 'could not open password file "<path>"' on IO errors, so
  the path needs scrubbing alongside the password. Mirrors the git SSH
  adapter pattern. Update test assertion accordingly.
- psql adapter: replace literal 'nil' context with 'context.TODO()'
  to silence staticcheck SA1012.
2026-05-28 18:17:49 +07:00

507 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) SetUserCredentialsTyped(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte, credentialType, hostScope *string) 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)
}
}