mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
fix(security): hard-deny ungranted exec for registered CLI binaries
Closes credential-scope bypass where an agent without a `secure_cli_agent_grants` row could still invoke a registered binary via shell fallthrough and pick up inherited env (`$GH_TOKEN`) or on-disk OAuth state. Registration is now an authorization boundary, not only a credential-injection hint. - Add `SecureCLIStore.IsRegisteredBinary` on PG + SQLite backends — case-insensitive, tenant-scoped. - New gate branch in `internal/tools/shell.go` (after normalization, before exec approval) with shell-wrapper unwrap depth 3 (sh/bash/zsh/dash -c, env K=V, nohup, stdbuf, timeout), 2s DB-lookup timeout, fail-CLOSED on error. New log events: `security.credentialed_binary_denied`, `security.credentialed_binary_gate_error`, `security.credentialed_binary_wrapper_too_deep`. - Env scrubbing on host fall-through (`internal/tools/env_scrub.go`) strips static credential keys (GH_TOKEN, AWS_*, OPENAI_API_KEY, …) plus dynamic keys from tenant's registered binaries; preserves HOME/PATH/TERM/LANG/USER/TZ. - Subagent ExecTool registration (`cmd/gateway_agents.go` → `buildSubagentToolsRegistry`) now receives the same `SecureCLIStore` — parent can't delegate to a child to bypass the gate. - Binary names lowercased on Create/Update for symmetry with case-insensitive lookup. - Thread `pgStores.SecureCLI` into `setupSubagents` in `cmd/gateway.go`. - 25+ unit tests (gate, env-scrub, SQLite IsRegisteredBinary) + 5 PG integration tests (deny-ungranted, allow-granted, unregistered-unchanged, is_global-not-denied regression, shell-wrapper-bypass denied). All green.
This commit is contained in:
1 parent
92dbb12c84
commit
7e2e66c095
16 files changed
+1588
-16
No files matched your search
+2
-2
@@ -242,8 +242,8 @@ func runGateway() {
|
||||
slog.Info("bootstrap: capabilities backfill complete", "agents", count)
|
||||
}
|
||||
|
||||
// Subagent system
|
||||
subagentMgr := setupSubagents(providerRegistry, cfg, msgBus, toolsReg, workspace, sandboxMgr)
|
||||
// Subagent system (secureCLI store wired so subagent ExecTools enforce the gate)
|
||||
subagentMgr := setupSubagents(providerRegistry, cfg, msgBus, toolsReg, workspace, sandboxMgr, pgStores.SecureCLI)
|
||||
if subagentMgr != nil {
|
||||
// Wire announce queue for batched subagent result delivery (matching TS debounce pattern).
|
||||
announceQueue := tools.NewAnnounceQueue(1000, 20, makeDelegateAnnounceCallback(subagentMgr, msgBus))
|
||||
|
||||
+37
-13
@@ -158,7 +158,7 @@ func buildEmbeddingProvider(
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupSubagents(providerReg *providers.Registry, cfg *config.Config, msgBus *bus.MessageBus, toolsReg *tools.Registry, workspace string, sandboxMgr sandbox.Manager) *tools.SubagentManager {
|
||||
func setupSubagents(providerReg *providers.Registry, cfg *config.Config, msgBus *bus.MessageBus, toolsReg *tools.Registry, workspace string, sandboxMgr sandbox.Manager, secureCLIStore store.SecureCLIStore) *tools.SubagentManager {
|
||||
names := providerReg.List(context.Background())
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
@@ -202,24 +202,48 @@ func setupSubagents(providerReg *providers.Registry, cfg *config.Config, msgBus
|
||||
// NOTE: SubagentManager.applyDenyList() handles deny lists after createTools(),
|
||||
// so we don't apply deny lists here.
|
||||
toolsFactory := func() *tools.Registry {
|
||||
reg := toolsReg.Clone()
|
||||
if sandboxMgr != nil {
|
||||
reg.Register(tools.NewSandboxedReadFileTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
|
||||
reg.Register(tools.NewSandboxedWriteFileTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
|
||||
reg.Register(tools.NewSandboxedListFilesTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
|
||||
reg.Register(tools.NewSandboxedExecTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
|
||||
} else {
|
||||
reg.Register(tools.NewReadFileTool(workspace, agentCfg.RestrictToWorkspace))
|
||||
reg.Register(tools.NewWriteFileTool(workspace, agentCfg.RestrictToWorkspace))
|
||||
reg.Register(tools.NewListFilesTool(workspace, agentCfg.RestrictToWorkspace))
|
||||
reg.Register(tools.NewExecTool(workspace, agentCfg.RestrictToWorkspace))
|
||||
}
|
||||
reg, _ := buildSubagentToolsRegistry(toolsReg, workspace, agentCfg.RestrictToWorkspace, sandboxMgr, secureCLIStore)
|
||||
return reg
|
||||
}
|
||||
|
||||
return tools.NewSubagentManager(provider, providerReg, agentCfg.Model, msgBus, toolsFactory, subCfg)
|
||||
}
|
||||
|
||||
// buildSubagentToolsRegistry produces a cloned tool registry for a subagent
|
||||
// with workspace-scoped file/exec tools registered. The returned ExecTool is
|
||||
// also returned for test assertion (Red Team F3): callers verify that the
|
||||
// secureCLIStore is wired so the subagent's exec path enforces the gate.
|
||||
func buildSubagentToolsRegistry(
|
||||
parentReg *tools.Registry,
|
||||
workspace string,
|
||||
restrict bool,
|
||||
sandboxMgr sandbox.Manager,
|
||||
secureCLIStore store.SecureCLIStore,
|
||||
) (*tools.Registry, *tools.ExecTool) {
|
||||
reg := parentReg.Clone()
|
||||
var execTool *tools.ExecTool
|
||||
if sandboxMgr != nil {
|
||||
reg.Register(tools.NewSandboxedReadFileTool(workspace, restrict, sandboxMgr))
|
||||
reg.Register(tools.NewSandboxedWriteFileTool(workspace, restrict, sandboxMgr))
|
||||
reg.Register(tools.NewSandboxedListFilesTool(workspace, restrict, sandboxMgr))
|
||||
execTool = tools.NewSandboxedExecTool(workspace, restrict, sandboxMgr)
|
||||
reg.Register(execTool)
|
||||
} else {
|
||||
reg.Register(tools.NewReadFileTool(workspace, restrict))
|
||||
reg.Register(tools.NewWriteFileTool(workspace, restrict))
|
||||
reg.Register(tools.NewListFilesTool(workspace, restrict))
|
||||
execTool = tools.NewExecTool(workspace, restrict)
|
||||
reg.Register(execTool)
|
||||
}
|
||||
// Red Team F3: subagent ExecTool must enforce the secure-CLI gate
|
||||
// (and env scrub on fall-through) — without this, a parent agent
|
||||
// can spawn a subagent to bypass the gate via host-inherited env.
|
||||
if secureCLIStore != nil {
|
||||
execTool.SetSecureCLIStore(secureCLIStore)
|
||||
}
|
||||
return reg, execTool
|
||||
}
|
||||
|
||||
// setupTTS creates the TTS manager from config and registers providers.
|
||||
// Edge TTS is always registered (free, no API key required).
|
||||
// Always returns a non-nil manager with at least one provider.
|
||||
|
||||
@@ -7,9 +7,85 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// --- Red Team F3: subagent ExecTool must inherit secureCLIStore wiring ---
|
||||
|
||||
type stubSecureCLIStoreCmd struct{}
|
||||
|
||||
func (s *stubSecureCLIStoreCmd) Create(ctx context.Context, b *store.SecureCLIBinary) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubSecureCLIStoreCmd) Get(ctx context.Context, id uuid.UUID) (*store.SecureCLIBinary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSecureCLIStoreCmd) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubSecureCLIStoreCmd) Delete(ctx context.Context, id uuid.UUID) error { return nil }
|
||||
func (s *stubSecureCLIStoreCmd) List(ctx context.Context) ([]store.SecureCLIBinary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSecureCLIStoreCmd) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSecureCLIStoreCmd) ListForAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSecureCLIStoreCmd) IsRegisteredBinary(ctx context.Context, binaryName string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (s *stubSecureCLIStoreCmd) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID, userID string) (*store.SecureCLIBinary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSecureCLIStoreCmd) GetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) (*store.SecureCLIUserCredential, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSecureCLIStoreCmd) SetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubSecureCLIStoreCmd) DeleteUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubSecureCLIStoreCmd) ListUserCredentials(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIUserCredential, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestSubagentExecTool_StoreWired ensures the subagent tool factory wires the
|
||||
// SecureCLIStore into the subagent's ExecTool, so the gate enforces on
|
||||
// spawned-subagent exec (Red Team F3). A missing wiring would let a parent
|
||||
// agent bypass the gate by delegating the exec to a subagent.
|
||||
func TestSubagentExecTool_StoreWired(t *testing.T) {
|
||||
parent := tools.NewRegistry()
|
||||
stub := &stubSecureCLIStoreCmd{}
|
||||
|
||||
_, execTool := buildSubagentToolsRegistry(parent, t.TempDir(), false, nil, stub)
|
||||
if execTool == nil {
|
||||
t.Fatal("expected non-nil exec tool from factory")
|
||||
}
|
||||
if !execTool.HasSecureCLIStore() {
|
||||
t.Fatalf("expected subagent ExecTool to have SecureCLIStore wired (Red Team F3)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubagentExecTool_NilStoreIsSafe ensures the factory does not panic when
|
||||
// the store is unavailable (Lite edition / no encryption key). The exec tool
|
||||
// simply lacks the gate — same as today's Lite behavior.
|
||||
func TestSubagentExecTool_NilStoreIsSafe(t *testing.T) {
|
||||
parent := tools.NewRegistry()
|
||||
_, execTool := buildSubagentToolsRegistry(parent, t.TempDir(), false, nil, nil)
|
||||
if execTool == nil {
|
||||
t.Fatal("expected non-nil exec tool from factory")
|
||||
}
|
||||
if execTool.HasSecureCLIStore() {
|
||||
t.Fatalf("expected no SecureCLIStore when passed nil (Lite path)")
|
||||
}
|
||||
}
|
||||
|
||||
func captureEmbeddingRequest(t *testing.T, es *store.EmbeddingSettings) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -293,6 +293,39 @@ The `exec` tool allows the LLM to run shell commands, with multiple defense laye
|
||||
}
|
||||
```
|
||||
|
||||
#### Grant enforcement (hard deny)
|
||||
|
||||
Registering a binary is not the same as letting every agent exec it. A registered
|
||||
binary with `is_global=false` **requires a row in `secure_cli_agent_grants`**
|
||||
for the calling agent; otherwise shell exec is denied before any process runs.
|
||||
Closes the class of bypass where an ungranted agent could still invoke the
|
||||
binary via host fallthrough and pick up inherited env (`$GH_TOKEN`, etc.) or
|
||||
on-disk OAuth state (`~/.config/gh`).
|
||||
|
||||
- **Enforcement site:** `internal/tools/shell.go` — gate runs after normalization
|
||||
and before exec approval, keyed on `store.SecureCLIStore.IsRegisteredBinary`.
|
||||
- **Name matching:** `filepath.Base(strings.TrimSpace(strings.ToLower(name)))` —
|
||||
case-insensitive (macOS APFS), directory-prefix stripped, trims whitespace.
|
||||
- **Shell wrapper unwrap:** detects and peels `sh -c / bash -c / zsh -c / dash -c`,
|
||||
leading `env K=V ...`, `nohup`, `stdbuf`, `timeout ...` up to depth 3. Commands
|
||||
nested deeper than 3 wrappers are rejected as adversarial.
|
||||
- **Global binaries:** `is_global=true` skips the gate (no grant needed). Shared
|
||||
utilities without per-agent scoping.
|
||||
- **Fail-CLOSED:** if the grant lookup errors (DB down, timeout) the exec is
|
||||
denied with a retry message. 2s per-lookup context timeout.
|
||||
- **Env scrubbing on fall-through:** when a command bypasses the credentialed
|
||||
path and runs on the host, the child process env is scrubbed of credential
|
||||
keys (static deny list + dynamic keys from every registered binary of the
|
||||
same tenant) before spawn. Prevents `$GH_TOKEN` leaking into a non-gh command.
|
||||
- **Log events:**
|
||||
- `security.credentialed_binary_denied` — agent attempted an ungranted
|
||||
registered binary. Fields: `binary, wrapper, agent_id, tenant_id, command_prefix`.
|
||||
- `security.credentialed_binary_gate_error` — lookup failed; exec denied.
|
||||
- `security.credentialed_binary_wrapper_too_deep` — >3 shell wrappers.
|
||||
- **Subagents:** subagent `ExecTool`s are wired with the same `SecureCLIStore`
|
||||
(`cmd/gateway_agents.go` → `buildSubagentToolsRegistry`) so a parent cannot
|
||||
bypass the gate by delegating the exec to a spawned child.
|
||||
|
||||
---
|
||||
|
||||
### Deny Patterns
|
||||
|
||||
@@ -111,6 +111,17 @@ All four filesystem tools (`read_file`, `write_file`, `list_files`, `edit`) impl
|
||||
- Sandbox escape → Docker container isolation if sandbox enabled
|
||||
- Verbose flag leakage → Separate deny_verbose list blocks verbose/debug output
|
||||
|
||||
**Agent-level grant enforcement** -- The gate runs **before** any process spawn, blocking ungranted agents from executing registered binaries:
|
||||
|
||||
| Control | Implementation |
|
||||
|---------|-----------------|
|
||||
| **Grant lookup** | `store.SecureCLIStore.IsRegisteredBinary(ctx, binaryName)` checks `secure_cli_agent_grants` table. Non-global binaries require a row for the calling agent. |
|
||||
| **Fail-CLOSED** | If the grant lookup errors (DB down, timeout), exec is denied with retry message. Per-lookup timeout: 2 seconds. |
|
||||
| **Env scrubbing** | When a command escapes the credentialed path (e.g., via adversarial `exec` tool), child process env is scrubbed of all credential keys (static deny list + dynamic keys from every registered binary in the tenant) before spawn. Prevents credential leakage into non-credentialed commands. |
|
||||
| **Wrapper unwrap** | Blocks shell wrappers (`sh -c`, `bash -c`, etc.) that attempt to evade binary path matching. Checks up to 3 levels of nesting; deeper chains are rejected as adversarial. |
|
||||
| **Logging** | Three security events: `security.credentialed_binary_denied` (ungranted agent), `security.credentialed_binary_gate_error` (lookup failure), `security.credentialed_binary_wrapper_too_deep` (nested wrapper attack). All include: binary, wrapper, agent_id, tenant_id, command prefix. |
|
||||
| **Subagent wiring** | Subagent `ExecTool`s use the same `SecureCLIStore` via `cmd/gateway_agents.go` → `buildSubagentToolsRegistry`. Parent agents cannot bypass the gate by delegating exec to spawned subagents. |
|
||||
|
||||
### Layer 4: Output Security
|
||||
|
||||
| Mechanism | Detail |
|
||||
|
||||
@@ -4,6 +4,30 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep
|
||||
|
||||
---
|
||||
|
||||
### Secure CLI grant enforcement — registered binaries now hard-deny ungranted exec (2026-04-17)
|
||||
|
||||
Closes a credential-scope bypass: an agent with no `secure_cli_agent_grants` row for a registered binary could still invoke it via shell fallthrough (`gh ...`, `sh -c 'gh ...'`) and pick up inherited env (`$GH_TOKEN`) or on-disk OAuth state (`~/.config/gh`). Registration is now an authorization boundary, not only a credential-injection hint.
|
||||
|
||||
#### Security
|
||||
|
||||
- **Shell gate (`internal/tools/shell.go`):** new enforcement branch runs after command normalization and before exec approval. Queries `SecureCLIStore.IsRegisteredBinary` per candidate; registered-but-ungranted binaries return `Binary %q requires a secure CLI grant. Ask admin to grant access to this agent.` Case-insensitive match (`filepath.Base(strings.ToLower(name))`) — macOS APFS safe.
|
||||
- **Wrapper unwrap (depth 3):** strips leading `sh -c / bash -c / zsh -c / dash -c`, `env K=V ...`, `nohup`, `stdbuf`, `timeout ...` before the check, so `sh -c 'gh api ...'` still denies. Nesting beyond 3 is rejected as adversarial.
|
||||
- **Fail-CLOSED:** lookup errors (DB down, 2s timeout) deny exec with a retry message, never fall through to host exec. New log events: `security.credentialed_binary_denied`, `security.credentialed_binary_gate_error`, `security.credentialed_binary_wrapper_too_deep`.
|
||||
- **Env scrubbing on fall-through (`internal/tools/env_scrub.go`):** child-process env is stripped of credential keys before spawn — static deny list (`GH_TOKEN`, `AWS_SECRET_ACCESS_KEY`, `OPENAI_API_KEY`, …) plus dynamic keys collected from every registered binary of the tenant. `HOME`, `PATH`, `TERM`, `LANG`, `USER`, `TZ` preserved. Prevents a non-`gh` command from inheriting `$GH_TOKEN`.
|
||||
- **Subagent wiring (`cmd/gateway_agents.go`):** subagent `ExecTool` registrations now receive the same `SecureCLIStore` via `buildSubagentToolsRegistry`. Parent agent can no longer bypass the gate by delegating the exec to a spawned child.
|
||||
- **`store.SecureCLIStore.IsRegisteredBinary`:** new method on both PG (`internal/store/pg/secure_cli.go`) and SQLite (`internal/store/sqlitestore/secure-cli.go`) backends. Scoped to tenant, returns case-insensitive match.
|
||||
|
||||
#### Affected editions
|
||||
|
||||
- **Standard (PG):** full gate.
|
||||
- **Lite (desktop/SQLite):** same gate via SQLite-backed `SecureCLIStore` — build-tag `sqliteonly` verified.
|
||||
|
||||
#### Migration
|
||||
|
||||
None. Existing binaries default `is_global=true` (no grant required) so current deployments are unaffected. To restrict a binary, set `is_global=false` and insert `secure_cli_agent_grants` rows per agent.
|
||||
|
||||
---
|
||||
|
||||
### ACTOR vs SCOPE — #915 group permission fix + propagation (2026-04-16)
|
||||
|
||||
Resolves Issue #915 (Telegram group `write_file` permission denied after `/addwriter`) and closes an adjacent silent-privilege-bypass discovered during the audit.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -41,6 +42,10 @@ func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary)
|
||||
b.ID = store.GenNewID()
|
||||
}
|
||||
|
||||
// Normalize binary_name to lowercase so IsRegisteredBinary (which lowercases
|
||||
// the candidate) can match. Admin entering "Gh" becomes "gh".
|
||||
b.BinaryName = strings.ToLower(strings.TrimSpace(b.BinaryName))
|
||||
|
||||
// Encrypt env if provided
|
||||
var envBytes []byte
|
||||
if len(b.EncryptedEnv) > 0 && s.encKey != "" {
|
||||
@@ -183,6 +188,13 @@ func (s *PGSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates map
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize binary_name to lowercase if updated (parity with Create).
|
||||
if nameVal, ok := updates["binary_name"]; ok {
|
||||
if nameStr, isStr := nameVal.(string); isStr {
|
||||
updates["binary_name"] = strings.ToLower(strings.TrimSpace(nameStr))
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt env if present in updates
|
||||
if envVal, ok := updates["encrypted_env"]; ok {
|
||||
if envStr, isStr := envVal.(string); isStr && envStr != "" && s.encKey != "" {
|
||||
@@ -408,6 +420,35 @@ func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBi
|
||||
return s.scanRows(rows)
|
||||
}
|
||||
|
||||
// IsRegisteredBinary reports whether a binary requires a grant (is_global=false)
|
||||
// and is enabled for the current tenant. See interface godoc for rationale.
|
||||
func (s *PGSecureCLIStore) IsRegisteredBinary(ctx context.Context, binaryName string) (bool, error) {
|
||||
name := strings.ToLower(strings.TrimSpace(binaryName))
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
query := `SELECT EXISTS(
|
||||
SELECT 1 FROM secure_cli_binaries
|
||||
WHERE LOWER(binary_name) = $1
|
||||
AND enabled = true
|
||||
AND is_global = false`
|
||||
args := []any{name}
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return false, nil
|
||||
}
|
||||
query += ` AND tenant_id = $2`
|
||||
args = append(args, tid)
|
||||
}
|
||||
query += `)`
|
||||
var exists bool
|
||||
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&exists); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// ListForAgent returns all CLIs accessible by an agent (global + granted),
|
||||
// with grant overrides merged into the returned configs.
|
||||
func (s *PGSecureCLIStore) ListForAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) {
|
||||
|
||||
@@ -94,6 +94,15 @@ type SecureCLIStore interface {
|
||||
// with grant overrides merged into the returned configs.
|
||||
ListForAgent(ctx context.Context, agentID uuid.UUID) ([]SecureCLIBinary, error)
|
||||
|
||||
// IsRegisteredBinary reports whether a binary with the given name is
|
||||
// registered and enabled for the tenant in ctx AND requires a grant
|
||||
// (is_global = false). Used by the shell exec gate to hard-deny
|
||||
// execution of credentialed binaries when the calling agent has no
|
||||
// grant. is_global = true binaries are open to all agents and MUST
|
||||
// NOT be reported as gate-needing. Tenant-scoped unless IsCrossTenant(ctx).
|
||||
// Returns (false, nil) when name is empty or tenant context is missing.
|
||||
IsRegisteredBinary(ctx context.Context, binaryName string) (bool, error)
|
||||
|
||||
// --- Per-user credential management ---
|
||||
|
||||
GetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) (*SecureCLIUserCredential, error)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -43,6 +44,10 @@ func (s *SQLiteSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBin
|
||||
b.ID = store.GenNewID()
|
||||
}
|
||||
|
||||
// Normalize binary_name to lowercase so IsRegisteredBinary (which lowercases
|
||||
// the candidate) can match. Admin entering "Gh" becomes "gh".
|
||||
b.BinaryName = strings.ToLower(strings.TrimSpace(b.BinaryName))
|
||||
|
||||
var envBytes []byte
|
||||
if len(b.EncryptedEnv) > 0 && s.encKey != "" {
|
||||
encrypted, err := crypto.Encrypt(string(b.EncryptedEnv), s.encKey)
|
||||
@@ -191,6 +196,13 @@ func (s *SQLiteSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize binary_name to lowercase if updated (parity with Create).
|
||||
if nameVal, ok := updates["binary_name"]; ok {
|
||||
if nameStr, isStr := nameVal.(string); isStr {
|
||||
updates["binary_name"] = strings.ToLower(strings.TrimSpace(nameStr))
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt env if present in updates
|
||||
if envVal, ok := updates["encrypted_env"]; ok {
|
||||
if envStr, isStr := envVal.(string); isStr && envStr != "" && s.encKey != "" {
|
||||
@@ -410,6 +422,35 @@ func (s *SQLiteSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureC
|
||||
return s.scanRows(rows)
|
||||
}
|
||||
|
||||
// IsRegisteredBinary reports whether a binary requires a grant (is_global=0)
|
||||
// and is enabled for the current tenant. See interface godoc for rationale.
|
||||
func (s *SQLiteSecureCLIStore) IsRegisteredBinary(ctx context.Context, binaryName string) (bool, error) {
|
||||
name := strings.ToLower(strings.TrimSpace(binaryName))
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
query := `SELECT EXISTS(
|
||||
SELECT 1 FROM secure_cli_binaries
|
||||
WHERE LOWER(binary_name) = ?
|
||||
AND enabled = 1
|
||||
AND is_global = 0`
|
||||
args := []any{name}
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return false, nil
|
||||
}
|
||||
query += ` AND tenant_id = ?`
|
||||
args = append(args, tid)
|
||||
}
|
||||
query += `)`
|
||||
var exists bool
|
||||
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&exists); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// ListForAgent returns all CLIs accessible by an agent (global + granted),
|
||||
// with grant overrides merged into the returned configs.
|
||||
func (s *SQLiteSecureCLIStore) ListForAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//go:build sqlite || sqliteonly
|
||||
|
||||
package sqlitestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// testEncKey is a 32-byte AES key used for SQLite secure-cli tests.
|
||||
// Its actual value is irrelevant for IsRegisteredBinary (metadata-only query).
|
||||
const testEncKey = "test-key-32-bytes-aaaaaaaaaaaaaaa"
|
||||
|
||||
func newTestSQLiteSecureCLI(t *testing.T) (*SQLiteSecureCLIStore, *sql.DB) {
|
||||
t.Helper()
|
||||
db, err := OpenDB(filepath.Join(t.TempDir(), "secure_cli.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := EnsureSchema(db); err != nil {
|
||||
t.Fatalf("EnsureSchema: %v", err)
|
||||
}
|
||||
return NewSQLiteSecureCLIStore(db, testEncKey), db
|
||||
}
|
||||
|
||||
// seedTenant inserts a tenant row and returns its ID.
|
||||
func seedTenant(t *testing.T, db *sql.DB, slug string) uuid.UUID {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO tenants (id, name, slug, status) VALUES (?, ?, ?, 'active')`,
|
||||
id, "Tenant-"+slug, slug,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed tenant %s: %v", slug, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// seedBinary inserts a secure_cli_binaries row with the given fields.
|
||||
func seedBinary(t *testing.T, db *sql.DB, tenantID uuid.UUID, name string, enabled, isGlobal bool) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO secure_cli_binaries
|
||||
(id, binary_name, encrypted_env, is_global, enabled, tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
uuid.New(), name, []byte("{}"), isGlobal, enabled, tenantID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed binary %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_IsRegisteredBinary_ReturnsTrueForEnabledNonGlobal(t *testing.T) {
|
||||
s, db := newTestSQLiteSecureCLI(t)
|
||||
tid := seedTenant(t, db, "t-true")
|
||||
seedBinary(t, db, tid, "gh", true, false)
|
||||
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
got, err := s.IsRegisteredBinary(ctx, "gh")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Fatalf("expected true for enabled non-global binary")
|
||||
}
|
||||
}
|
||||
|
||||
// Red Team F2 regression guard: is_global=true must NOT be reported as
|
||||
// gate-needing — those binaries are open to all agents without a grant.
|
||||
func TestSQLite_IsRegisteredBinary_FalseForGlobalBinary(t *testing.T) {
|
||||
s, db := newTestSQLiteSecureCLI(t)
|
||||
tid := seedTenant(t, db, "t-global")
|
||||
seedBinary(t, db, tid, "ls", true, true)
|
||||
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
got, err := s.IsRegisteredBinary(ctx, "ls")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatalf("expected false for is_global=true binary (would deny access otherwise)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_IsRegisteredBinary_FalseForDisabled(t *testing.T) {
|
||||
s, db := newTestSQLiteSecureCLI(t)
|
||||
tid := seedTenant(t, db, "t-disabled")
|
||||
seedBinary(t, db, tid, "gh", false, false)
|
||||
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
got, err := s.IsRegisteredBinary(ctx, "gh")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatalf("expected false for disabled binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_IsRegisteredBinary_FalseForWrongTenant(t *testing.T) {
|
||||
s, db := newTestSQLiteSecureCLI(t)
|
||||
tidA := seedTenant(t, db, "t-a")
|
||||
tidB := seedTenant(t, db, "t-b")
|
||||
seedBinary(t, db, tidA, "gh", true, false)
|
||||
|
||||
ctx := store.WithTenantID(context.Background(), tidB)
|
||||
got, err := s.IsRegisteredBinary(ctx, "gh")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatalf("expected false across tenants")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_IsRegisteredBinary_FalseForUnknownName(t *testing.T) {
|
||||
s, db := newTestSQLiteSecureCLI(t)
|
||||
tid := seedTenant(t, db, "t-unk")
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
got, err := s.IsRegisteredBinary(ctx, "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatalf("expected false for unknown name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_IsRegisteredBinary_RespectsCrossTenant(t *testing.T) {
|
||||
s, db := newTestSQLiteSecureCLI(t)
|
||||
tid := seedTenant(t, db, "t-xtenant")
|
||||
seedBinary(t, db, tid, "gh", true, false)
|
||||
|
||||
ctx := store.WithCrossTenant(context.Background())
|
||||
got, err := s.IsRegisteredBinary(ctx, "gh")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Fatalf("expected true under cross-tenant ctx")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_IsRegisteredBinary_EmptyNameReturnsFalse(t *testing.T) {
|
||||
s, _ := newTestSQLiteSecureCLI(t)
|
||||
ctx := store.WithCrossTenant(context.Background())
|
||||
got, err := s.IsRegisteredBinary(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatalf("expected false for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_IsRegisteredBinary_NilTenantNotCrossTenant(t *testing.T) {
|
||||
s, _ := newTestSQLiteSecureCLI(t)
|
||||
got, err := s.IsRegisteredBinary(context.Background(), "gh")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatalf("expected false when tenant unset and not cross-tenant")
|
||||
}
|
||||
}
|
||||
|
||||
// Red Team F8: case-insensitive match — macOS/APFS resolves GH → gh.
|
||||
func TestSQLite_IsRegisteredBinary_CaseInsensitive(t *testing.T) {
|
||||
s, db := newTestSQLiteSecureCLI(t)
|
||||
tid := seedTenant(t, db, "t-case")
|
||||
seedBinary(t, db, tid, "gh", true, false)
|
||||
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
for _, q := range []string{"gh", "GH", "Gh", " gh "} {
|
||||
got, err := s.IsRegisteredBinary(ctx, q)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err for %q: %v", q, err)
|
||||
}
|
||||
if !got {
|
||||
t.Fatalf("expected true for %q (case-insensitive)", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -22,6 +23,141 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// maxWrapperDepth is the hard cap on shell-wrapper unwrapping. Commands nested
|
||||
// deeper than this are denied unconditionally as adversarial — real commands
|
||||
// never wrap beyond depth 3.
|
||||
const maxWrapperDepth = 3
|
||||
|
||||
// wrapperBinaries identifies shell/exec wrappers whose first arg after -c is
|
||||
// the real command to gate. Key is the normalized base name.
|
||||
var wrapperBinaries = map[string]bool{
|
||||
"sh": true, "bash": true, "zsh": true, "dash": true,
|
||||
"env": true, "nohup": true, "stdbuf": true, "timeout": true,
|
||||
}
|
||||
|
||||
// normalizeBinaryName returns the lowercased file base of a binary reference.
|
||||
// Examples: "/usr/bin/gh" → "gh", "./GH" → "gh", " Gh " → "gh".
|
||||
// Applied at BOTH the gate lookup and lookupCredentialedBinary so the two
|
||||
// layers agree on identity. (Red Team F5)
|
||||
func normalizeBinaryName(s string) string {
|
||||
return filepath.Base(strings.TrimSpace(strings.ToLower(s)))
|
||||
}
|
||||
|
||||
// detectWrapper recognises shell-wrapper invocations and returns the inner
|
||||
// command string. Supported shapes:
|
||||
//
|
||||
// sh -c "<inner>" (also bash / zsh / dash / /bin/sh / /usr/bin/env sh ...)
|
||||
// env [K=V ...] <cmd> ... (no -c; the real binary is <cmd>)
|
||||
// nohup <cmd> ...
|
||||
// stdbuf -oL <cmd> ...
|
||||
// timeout 10 <cmd> ...
|
||||
//
|
||||
// Returns wrapper=normalized wrapper name, innerCmd=remaining command string.
|
||||
// ok=false when cmd is not a recognised wrapper or parsing fails.
|
||||
func detectWrapper(cmd string) (wrapper string, innerCmd string, ok bool) {
|
||||
parser := shellwords.NewParser()
|
||||
parser.ParseBacktick = false
|
||||
parser.ParseEnv = false
|
||||
words, err := parser.Parse(cmd)
|
||||
if err != nil || len(words) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
head := normalizeBinaryName(words[0])
|
||||
if !wrapperBinaries[head] {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
switch head {
|
||||
case "sh", "bash", "zsh", "dash":
|
||||
// sh -c "<inner>" → inner is word[2].
|
||||
for i := 1; i < len(words); i++ {
|
||||
if words[i] == "-c" && i+1 < len(words) {
|
||||
return head, words[i+1], true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
case "env":
|
||||
// env [K=V ...] <cmd> [args...] OR env -S "<cmd args>"
|
||||
for i := 1; i < len(words); i++ {
|
||||
w := words[i]
|
||||
if strings.Contains(w, "=") && !strings.HasPrefix(w, "-") {
|
||||
continue // env var assignment, skip
|
||||
}
|
||||
if w == "-i" || w == "-u" || w == "-" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(w, "-") {
|
||||
// Unknown env flag — bail out of wrapper detection.
|
||||
return "", "", false
|
||||
}
|
||||
// First non-assignment, non-flag token is the real command.
|
||||
return "env", strings.Join(append([]string{w}, words[i+1:]...), " "), true
|
||||
}
|
||||
return "", "", false
|
||||
case "nohup":
|
||||
if len(words) < 2 {
|
||||
return "", "", false
|
||||
}
|
||||
return "nohup", strings.Join(words[1:], " "), true
|
||||
case "stdbuf":
|
||||
// stdbuf [-oL -eL -iL ...] <cmd> ...
|
||||
for i := 1; i < len(words); i++ {
|
||||
if strings.HasPrefix(words[i], "-") {
|
||||
continue
|
||||
}
|
||||
return "stdbuf", strings.Join(words[i:], " "), true
|
||||
}
|
||||
return "", "", false
|
||||
case "timeout":
|
||||
// timeout [--foreground] [-k DUR] <DURATION> <cmd> ...
|
||||
for i := 1; i < len(words); i++ {
|
||||
w := words[i]
|
||||
if strings.HasPrefix(w, "-") {
|
||||
continue
|
||||
}
|
||||
// First non-flag token is the DURATION — skip it.
|
||||
if i+1 < len(words) {
|
||||
return "timeout", strings.Join(words[i+1:], " "), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// gateCandidate is one step of wrapper unwrapping; it captures the binary
|
||||
// name to gate and (optionally) the wrapper token that introduced it.
|
||||
type gateCandidate struct {
|
||||
binary string // normalized (lowercase, file base)
|
||||
wrapper string // "" for outermost direct invocation; else wrapper name
|
||||
}
|
||||
|
||||
// collectGateCandidates returns the ordered list of binaries extracted from
|
||||
// a command via recursive wrapper unwrapping (outermost → innermost).
|
||||
// tooDeep=true when unwrapping exceeds maxWrapperDepth — callers must deny.
|
||||
func collectGateCandidates(cmd string) (candidates []gateCandidate, tooDeep bool) {
|
||||
current := cmd
|
||||
wrapper := ""
|
||||
for depth := 0; ; depth++ {
|
||||
bin, _, err := parseCommandBinary(current)
|
||||
if err != nil || bin == "" {
|
||||
return candidates, false
|
||||
}
|
||||
norm := normalizeBinaryName(bin)
|
||||
candidates = append(candidates, gateCandidate{binary: norm, wrapper: wrapper})
|
||||
w, inner, ok := detectWrapper(current)
|
||||
if !ok || strings.TrimSpace(inner) == "" {
|
||||
return candidates, false
|
||||
}
|
||||
if depth+1 > maxWrapperDepth {
|
||||
return candidates, true
|
||||
}
|
||||
wrapper = w
|
||||
current = inner
|
||||
}
|
||||
}
|
||||
|
||||
// shellOperatorPattern detects shell metacharacters that indicate command chaining.
|
||||
// These are unsafe in credentialed mode because they allow reading injected env vars.
|
||||
var shellOperatorPattern = regexp.MustCompile(`[;|&<>\n\r` + "`" + `]|\$\(|\$\{`)
|
||||
@@ -417,6 +553,10 @@ func (t *ExecTool) lookupCredentialedBinary(ctx context.Context, command string)
|
||||
if err != nil {
|
||||
return nil, "", nil
|
||||
}
|
||||
// Normalize lookup key so path/case variants (/usr/bin/gh, ./gh, GH) all
|
||||
// resolve to the same registry row. Same helper is used by the gate
|
||||
// branch in Execute — identity must agree at both layers. (Red Team F5)
|
||||
normBinary := normalizeBinaryName(binary)
|
||||
// Get agent ID from context for scoped lookup
|
||||
agentID := store.AgentIDFromContext(ctx)
|
||||
var agentIDPtr *uuid.UUID
|
||||
@@ -427,7 +567,7 @@ func (t *ExecTool) lookupCredentialedBinary(ctx context.Context, command string)
|
||||
// Uses CredentialUserIDFromContext to pick up merged tenant user identity
|
||||
// (falls back to UserIDFromContext when not set).
|
||||
userID := store.CredentialUserIDFromContext(ctx)
|
||||
cred, err := t.secureCLIStore.LookupByBinary(ctx, binary, agentIDPtr, userID)
|
||||
cred, err := t.secureCLIStore.LookupByBinary(ctx, normBinary, agentIDPtr, userID)
|
||||
if err != nil {
|
||||
slog.Warn("secure_cli.lookup: query failed", "binary", binary, "agent_id", agentID, "error", err)
|
||||
return nil, "", nil
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// staticCredentialEnvKeys are always stripped from fall-through exec env.
|
||||
// These cover the most common provider / CLI credentials so an agent that
|
||||
// runs a non-credentialed command cannot exfiltrate host secrets via the
|
||||
// child process environment.
|
||||
var staticCredentialEnvKeys = []string{
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GH_ENTERPRISE_TOKEN",
|
||||
"GH_CONFIG_DIR",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"NPM_TOKEN",
|
||||
"DOCKER_PASSWORD",
|
||||
"DOCKER_AUTH",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"HUGGINGFACE_TOKEN",
|
||||
"HF_TOKEN",
|
||||
"GCP_SA_KEY",
|
||||
"AZURE_CLIENT_SECRET",
|
||||
"CLOUDFLARE_API_TOKEN",
|
||||
}
|
||||
|
||||
// scrubCredentialEnv returns env with any KEY=VALUE pair removed whose key
|
||||
// matches staticCredentialEnvKeys or any key in dynamicKeys. Comparison is
|
||||
// case-sensitive per POSIX — env names are case-sensitive.
|
||||
func scrubCredentialEnv(env []string, dynamicKeys []string) []string {
|
||||
if len(env) == 0 {
|
||||
return env
|
||||
}
|
||||
deny := make(map[string]struct{}, len(staticCredentialEnvKeys)+len(dynamicKeys))
|
||||
for _, k := range staticCredentialEnvKeys {
|
||||
deny[k] = struct{}{}
|
||||
}
|
||||
for _, k := range dynamicKeys {
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
deny[k] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(env))
|
||||
for _, kv := range env {
|
||||
i := strings.IndexByte(kv, '=')
|
||||
if i <= 0 {
|
||||
out = append(out, kv)
|
||||
continue
|
||||
}
|
||||
if _, drop := deny[kv[:i]]; drop {
|
||||
continue
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// credentialEnvKeys returns the union of static deny-list keys and any
|
||||
// dynamic keys discovered via the secure-cli registry (encrypted_env for
|
||||
// each enabled binary scoped to the caller's tenant).
|
||||
//
|
||||
// No caching: tenant scope comes from ctx, and a shared ExecTool instance
|
||||
// serves all tenants. Caching would require a per-tenant map with
|
||||
// invalidation on binary Create/Update/Delete — out of scope here (YAGNI).
|
||||
// This path runs only on host fall-through exec, which is already the
|
||||
// slow path. One ListEnabled query is acceptable.
|
||||
//
|
||||
// Fails soft: store errors fall back to the static list — env scrubbing
|
||||
// never blocks exec on a DB hiccup.
|
||||
func (t *ExecTool) credentialEnvKeys(ctx context.Context) []string {
|
||||
if t.secureCLIStore == nil {
|
||||
return staticCredentialEnvKeys
|
||||
}
|
||||
|
||||
bins, err := t.secureCLIStore.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("security.credentialed_env_scrub_list_error", "error", err)
|
||||
return staticCredentialEnvKeys
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(staticCredentialEnvKeys))
|
||||
merged := make([]string, 0, len(staticCredentialEnvKeys))
|
||||
for _, k := range staticCredentialEnvKeys {
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
merged = append(merged, k)
|
||||
}
|
||||
for _, b := range bins {
|
||||
for _, k := range extractJSONTopKeys(b.EncryptedEnv) {
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
merged = append(merged, k)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// extractJSONTopKeys returns the top-level string keys of a JSON object
|
||||
// without unmarshalling values. Tolerant: returns nil on any parse error.
|
||||
// Kept local to avoid pulling json into env_scrub for a tiny helper.
|
||||
func extractJSONTopKeys(data []byte) []string {
|
||||
s := strings.TrimSpace(string(data))
|
||||
if !strings.HasPrefix(s, "{") || !strings.HasSuffix(s, "}") {
|
||||
return nil
|
||||
}
|
||||
var keys []string
|
||||
inner := s[1 : len(s)-1]
|
||||
i := 0
|
||||
for i < len(inner) {
|
||||
// skip whitespace + commas
|
||||
for i < len(inner) && (inner[i] == ' ' || inner[i] == '\n' || inner[i] == '\t' || inner[i] == '\r' || inner[i] == ',') {
|
||||
i++
|
||||
}
|
||||
if i >= len(inner) || inner[i] != '"' {
|
||||
return keys
|
||||
}
|
||||
i++ // consume opening quote
|
||||
start := i
|
||||
for i < len(inner) && inner[i] != '"' {
|
||||
if inner[i] == '\\' && i+1 < len(inner) {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
if i > len(inner) {
|
||||
return keys
|
||||
}
|
||||
keys = append(keys, inner[start:i])
|
||||
// advance past closing quote + optional spaces + colon + value
|
||||
if i < len(inner) {
|
||||
i++
|
||||
}
|
||||
depth := 0
|
||||
inStr := false
|
||||
for i < len(inner) {
|
||||
c := inner[i]
|
||||
if inStr {
|
||||
if c == '\\' && i+1 < len(inner) {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
inStr = false
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"':
|
||||
inStr = true
|
||||
case '{', '[':
|
||||
depth++
|
||||
case '}', ']':
|
||||
depth--
|
||||
}
|
||||
if c == ',' && depth == 0 {
|
||||
i++
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func envContains(env []string, key string) bool {
|
||||
prefix := key + "="
|
||||
for _, kv := range env {
|
||||
if strings.HasPrefix(kv, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestScrubCredentialEnv_StripsStatic(t *testing.T) {
|
||||
in := []string{
|
||||
"HOME=/root",
|
||||
"GH_TOKEN=secret-abc",
|
||||
"PATH=/usr/bin",
|
||||
"AWS_SECRET_ACCESS_KEY=topsecret",
|
||||
}
|
||||
out := scrubCredentialEnv(in, nil)
|
||||
|
||||
if envContains(out, "GH_TOKEN") {
|
||||
t.Fatalf("GH_TOKEN must be scrubbed, got: %v", out)
|
||||
}
|
||||
if envContains(out, "AWS_SECRET_ACCESS_KEY") {
|
||||
t.Fatalf("AWS_SECRET_ACCESS_KEY must be scrubbed, got: %v", out)
|
||||
}
|
||||
if !envContains(out, "HOME") || !envContains(out, "PATH") {
|
||||
t.Fatalf("essential vars must be preserved, got: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubCredentialEnv_StripsDynamic(t *testing.T) {
|
||||
in := []string{
|
||||
"HOME=/root",
|
||||
"MY_CUSTOM_SECRET=hello",
|
||||
"KEEP_ME=yes",
|
||||
}
|
||||
out := scrubCredentialEnv(in, []string{"MY_CUSTOM_SECRET"})
|
||||
|
||||
if envContains(out, "MY_CUSTOM_SECRET") {
|
||||
t.Fatalf("MY_CUSTOM_SECRET must be scrubbed, got: %v", out)
|
||||
}
|
||||
if !envContains(out, "KEEP_ME") {
|
||||
t.Fatalf("KEEP_ME must be preserved, got: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubCredentialEnv_PreservesEssentials(t *testing.T) {
|
||||
in := []string{
|
||||
"HOME=/root",
|
||||
"PATH=/usr/bin",
|
||||
"TERM=xterm",
|
||||
"LANG=en_US.UTF-8",
|
||||
"USER=alice",
|
||||
"TZ=UTC",
|
||||
}
|
||||
out := scrubCredentialEnv(in, nil)
|
||||
for _, k := range []string{"HOME", "PATH", "TERM", "LANG", "USER", "TZ"} {
|
||||
if !envContains(out, k) {
|
||||
t.Fatalf("expected %s preserved, got: %v", k, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubCredentialEnv_PreservesUnrelated(t *testing.T) {
|
||||
in := []string{
|
||||
"FOO=bar",
|
||||
"RANDOM_APP_FLAG=1",
|
||||
"NPM_TOKEN=leakme", // static deny-list → should be scrubbed
|
||||
}
|
||||
out := scrubCredentialEnv(in, nil)
|
||||
if !envContains(out, "FOO") {
|
||||
t.Fatalf("FOO must be preserved, got: %v", out)
|
||||
}
|
||||
if !envContains(out, "RANDOM_APP_FLAG") {
|
||||
t.Fatalf("RANDOM_APP_FLAG must be preserved, got: %v", out)
|
||||
}
|
||||
if envContains(out, "NPM_TOKEN") {
|
||||
t.Fatalf("NPM_TOKEN must be scrubbed (static), got: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Basic smoke for the JSON key extractor used to feed dynamic keys.
|
||||
func TestExtractJSONTopKeys_Simple(t *testing.T) {
|
||||
keys := extractJSONTopKeys([]byte(`{"GH_TOKEN":"x","GH_CONFIG_DIR":"/tmp"}`))
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("expected 2 keys, got %d: %v", len(keys), keys)
|
||||
}
|
||||
want := map[string]bool{"GH_TOKEN": true, "GH_CONFIG_DIR": true}
|
||||
for _, k := range keys {
|
||||
if !want[k] {
|
||||
t.Fatalf("unexpected key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractJSONTopKeys_NestedIgnored(t *testing.T) {
|
||||
keys := extractJSONTopKeys([]byte(`{"A":{"nested":"v"},"B":"x"}`))
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("expected 2 top keys, got %d: %v", len(keys), keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractJSONTopKeys_Malformed(t *testing.T) {
|
||||
if keys := extractJSONTopKeys([]byte(`not json`)); keys != nil {
|
||||
t.Fatalf("expected nil on malformed input, got %v", keys)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -109,6 +110,13 @@ func (t *ExecTool) SetSecureCLIStore(s store.SecureCLIStore) {
|
||||
t.secureCLIStore = s
|
||||
}
|
||||
|
||||
// HasSecureCLIStore reports whether a credential store is wired.
|
||||
// Intended for wiring-check tests that verify subagent ExecTools also enforce
|
||||
// the secure-CLI gate (Red Team F3).
|
||||
func (t *ExecTool) HasSecureCLIStore() bool {
|
||||
return t.secureCLIStore != nil
|
||||
}
|
||||
|
||||
func (t *ExecTool) Name() string { return "exec" }
|
||||
func (t *ExecTool) Description() string { return "Execute a shell command and return its output" }
|
||||
func (t *ExecTool) Parameters() map[string]any {
|
||||
@@ -244,6 +252,45 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
|
||||
return t.executeCredentialed(ctx, cred, binary, cmdArgs, cwd, sandboxKey, command)
|
||||
}
|
||||
|
||||
// Secure CLI gate: registered-but-not-granted binaries MUST NOT fall through
|
||||
// to host exec with parent env. Works on the already-normalized command
|
||||
// (Red Team F6) and unwraps shell wrappers up to depth 3 (Red Team F1).
|
||||
// Fails CLOSED on DB error (Red Team F7).
|
||||
if t.secureCLIStore != nil {
|
||||
candidates, tooDeep := collectGateCandidates(normalizedCommand)
|
||||
if tooDeep {
|
||||
slog.Warn("security.credentialed_binary_wrapper_too_deep",
|
||||
"command", truncateCmd(normalizedCommand, 80),
|
||||
"agent_id", store.AgentIDFromContext(ctx))
|
||||
return ErrorResult("Command nesting too deep (>3 shell wrappers). This looks adversarial; if legitimate, flatten the command.")
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if c.binary == "" {
|
||||
continue
|
||||
}
|
||||
gctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
registered, rerr := t.secureCLIStore.IsRegisteredBinary(gctx, c.binary)
|
||||
cancel()
|
||||
if rerr != nil {
|
||||
slog.Warn("security.credentialed_binary_gate_error",
|
||||
"binary", c.binary, "error", rerr,
|
||||
"agent_id", store.AgentIDFromContext(ctx))
|
||||
return ErrorResult("Secure CLI gate temporarily unavailable. Retry in a moment.")
|
||||
}
|
||||
if registered {
|
||||
slog.Warn("security.credentialed_binary_denied",
|
||||
"binary", c.binary,
|
||||
"wrapper", c.wrapper,
|
||||
"agent_id", store.AgentIDFromContext(ctx),
|
||||
"tenant_id", store.TenantIDFromContext(ctx),
|
||||
"command_prefix", truncateCmd(normalizedCommand, 80))
|
||||
return ErrorResult(fmt.Sprintf(
|
||||
"Binary %q requires a secure CLI grant. Ask admin to grant access to this agent.",
|
||||
c.binary))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exec approval check (matching TS exec-approval.ts pipeline)
|
||||
if t.approvalMgr != nil {
|
||||
switch t.approvalMgr.CheckCommand(command) {
|
||||
@@ -318,6 +365,17 @@ func (t *ExecTool) executeOnHost(ctx context.Context, command, cwd string) *Resu
|
||||
cmd := exec.Command("sh", "-c", command)
|
||||
cmd.Dir = cwd
|
||||
|
||||
// Scrub credential env vars so fall-through exec cannot exfiltrate
|
||||
// host secrets (Red Team F4). Uses static deny list + dynamic keys
|
||||
// discovered from any registered secure-cli binary for this tenant.
|
||||
var dynKeys []string
|
||||
if t.secureCLIStore != nil {
|
||||
dynKeys = t.credentialEnvKeys(ctx)
|
||||
} else {
|
||||
dynKeys = staticCredentialEnvKeys
|
||||
}
|
||||
cmd.Env = scrubCredentialEnv(os.Environ(), dynKeys)
|
||||
|
||||
// Place the child in its own process group so killProcessGroup(-pgid, sig)
|
||||
// reaches the shell and all of its forked children.
|
||||
setProcessGroup(cmd)
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
result := tool.Execute(ctx, map[string]any{"command": "sh -c 'echo hi'"})
|
||||
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).
|
||||
result := tool.Execute(ctx, map[string]any{"command": `sh -c 'echo "token=$GH_TOKEN"'`})
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// gateTestBinaryName is deliberately NOT a real binary on PATH so the
|
||||
// "allowed" path can never accidentally exec something real.
|
||||
const gateTestBinaryName = "goclaw_test_cli"
|
||||
|
||||
// gateFixture holds the common seeds for gate enforcement tests.
|
||||
type gateFixture struct {
|
||||
db *sql.DB
|
||||
tool *tools.ExecTool
|
||||
tenantID uuid.UUID
|
||||
agentA uuid.UUID
|
||||
agentB uuid.UUID
|
||||
binaryID uuid.UUID
|
||||
}
|
||||
|
||||
// setupGateTest seeds: tenant, two agents under that SAME tenant (Red Team F10),
|
||||
// a non-global registered binary, and a grant only for agentA. Returns a wired
|
||||
// ExecTool + the IDs. All rows are cleaned up via t.Cleanup.
|
||||
func setupGateTest(t *testing.T) *gateFixture {
|
||||
t.Helper()
|
||||
|
||||
db := testDB(t)
|
||||
tenantID, agentA := seedTenantAgent(t, db)
|
||||
agentB := seedSecondAgent(t, db, tenantID)
|
||||
binaryID := seedGateBinary(t, db, tenantID, gateTestBinaryName, false)
|
||||
seedGrant(t, db, tenantID, binaryID, agentA)
|
||||
|
||||
secStore := pg.NewPGSecureCLIStore(db, testEncryptionKey)
|
||||
tool := tools.NewExecTool(t.TempDir(), false)
|
||||
tool.SetSecureCLIStore(secStore)
|
||||
|
||||
return &gateFixture{
|
||||
db: db,
|
||||
tool: tool,
|
||||
tenantID: tenantID,
|
||||
agentA: agentA,
|
||||
agentB: agentB,
|
||||
binaryID: binaryID,
|
||||
}
|
||||
}
|
||||
|
||||
// seedSecondAgent inserts an additional agent under an existing tenant. Used
|
||||
// to satisfy Red Team F10: both test agents must share the same tenant to
|
||||
// prove the gate (not tenant isolation) is what denies ungranted exec.
|
||||
func seedSecondAgent(t *testing.T, db *sql.DB, tenantID uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
|
||||
agentID := uuid.New()
|
||||
agentKey := "test-" + agentID.String()[:8]
|
||||
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
|
||||
VALUES ($1, $2, $3, 'predefined', 'active', 'test', 'test-model', 'test-owner')`,
|
||||
agentID, tenantID, agentKey)
|
||||
if err != nil {
|
||||
t.Fatalf("seed second agent: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM agents WHERE id = $1", agentID)
|
||||
})
|
||||
|
||||
return agentID
|
||||
}
|
||||
|
||||
// seedGateBinary inserts a secure_cli_binaries row with a caller-specified
|
||||
// binary_name and is_global flag. Matches shape used by seedSecureCLI.
|
||||
func seedGateBinary(t *testing.T, db *sql.DB, tenantID uuid.UUID, name string, isGlobal bool) uuid.UUID {
|
||||
t.Helper()
|
||||
|
||||
binaryID := uuid.New()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO secure_cli_binaries (id, tenant_id, binary_name, encrypted_env, description, enabled, is_global)
|
||||
VALUES ($1, $2, $3, $4, 'gate test CLI', true, $5)`,
|
||||
binaryID, tenantID, name, []byte(`{}`), isGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("seed gate binary: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM secure_cli_agent_grants WHERE binary_id = $1", binaryID)
|
||||
db.Exec("DELETE FROM secure_cli_user_credentials WHERE binary_id = $1", binaryID)
|
||||
db.Exec("DELETE FROM secure_cli_binaries WHERE id = $1", binaryID)
|
||||
})
|
||||
|
||||
return binaryID
|
||||
}
|
||||
|
||||
// seedGrant inserts a secure_cli_agent_grants row tying an agent to a binary.
|
||||
func seedGrant(t *testing.T, db *sql.DB, tenantID, binaryID, agentID uuid.UUID) {
|
||||
t.Helper()
|
||||
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO secure_cli_agent_grants (binary_id, agent_id, tenant_id, enabled)
|
||||
VALUES ($1, $2, $3, true)`,
|
||||
binaryID, agentID, tenantID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed grant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// gateCtx builds a ctx with tenant + agent set for gate enforcement.
|
||||
func gateCtx(tenantID, agentID uuid.UUID) context.Context {
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
return store.WithAgentID(ctx, agentID)
|
||||
}
|
||||
|
||||
// TestSecureCLIGate_DeniesUngranted proves the gate denies ungranted agents
|
||||
// for a registered, non-global binary (FR2 — the primary fix).
|
||||
func TestSecureCLIGate_DeniesUngranted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := setupGateTest(t)
|
||||
ctx := gateCtx(f.tenantID, f.agentB)
|
||||
|
||||
result := f.tool.Execute(ctx, map[string]any{
|
||||
"command": gateTestBinaryName + " --help",
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatalf("expected IsError=true for ungranted exec, got: %+v", result)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
|
||||
t.Fatalf("expected deny message, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecureCLIGate_AllowsGrantedAgent proves the gate permits a granted
|
||||
// agent past the deny branch (FR3). The downstream credentialed exec may
|
||||
// still fail because the binary is not on PATH — we assert only that the
|
||||
// deny message is NOT returned.
|
||||
func TestSecureCLIGate_AllowsGrantedAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := setupGateTest(t)
|
||||
ctx := gateCtx(f.tenantID, f.agentA)
|
||||
|
||||
result := f.tool.Execute(ctx, map[string]any{
|
||||
"command": gateTestBinaryName + " --help",
|
||||
})
|
||||
|
||||
if strings.Contains(result.ForLLM, "requires a secure CLI grant") {
|
||||
t.Fatalf("granted agent unexpectedly denied: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecureCLIGate_UnregisteredBinaryUnchanged proves the gate is a no-op
|
||||
// for binaries not in the registry (FR4). `echo` must run normally.
|
||||
func TestSecureCLIGate_UnregisteredBinaryUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
|
||||
secStore := pg.NewPGSecureCLIStore(db, testEncryptionKey)
|
||||
tool := tools.NewExecTool(t.TempDir(), false)
|
||||
tool.SetSecureCLIStore(secStore)
|
||||
|
||||
ctx := gateCtx(tenantID, agentID)
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"command": "echo hello",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected no error for unregistered binary, got: %s", result.ForLLM)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "hello") {
|
||||
t.Fatalf("expected output to contain 'hello', got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecureCLIGate_IsGlobalBinaryNotDenied is Red Team F2 regression guard.
|
||||
// A global binary (is_global=true) needs no grant; the gate must NOT deny.
|
||||
func TestSecureCLIGate_IsGlobalBinaryNotDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
seedGateBinary(t, db, tenantID, "goclaw_global_test", true)
|
||||
|
||||
secStore := pg.NewPGSecureCLIStore(db, testEncryptionKey)
|
||||
tool := tools.NewExecTool(t.TempDir(), false)
|
||||
tool.SetSecureCLIStore(secStore)
|
||||
|
||||
ctx := gateCtx(tenantID, agentID)
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"command": "goclaw_global_test --help",
|
||||
})
|
||||
|
||||
if strings.Contains(result.ForLLM, "requires a secure CLI grant") {
|
||||
t.Fatalf("global binary unexpectedly denied by gate: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecureCLIGate_ShellWrapperBypassDenied is Red Team F1 integration guard.
|
||||
// Wrapping the registered binary in `sh -c '...'` must still hit the deny path.
|
||||
func TestSecureCLIGate_ShellWrapperBypassDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := setupGateTest(t)
|
||||
ctx := gateCtx(f.tenantID, f.agentB)
|
||||
|
||||
result := f.tool.Execute(ctx, map[string]any{
|
||||
"command": "sh -c '" + gateTestBinaryName + " --help'",
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatalf("expected IsError=true for wrapped exec, got: %+v", result)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "requires a secure CLI grant") {
|
||||
t.Fatalf("expected deny message for sh -c wrap, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user