mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 04:20:16 +00:00
* 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.
507 lines
18 KiB
Go
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)
|
|
}
|
|
}
|