Files
goclaw/docs/19-credentialed-exec.md
T

14 KiB

19 - Credentialed Exec

Credentialed Exec allows GoClaw agents to use external CLI tools (gh, gcloud, aws, kubectl, terraform) with auto-injected credentials. Credentials are encrypted at rest and injected directly into child processes via Direct Exec Mode — never exposed to the LLM, never passed through a shell.

References: Issue #197 · PR #199


1. Architecture

flowchart TD
    A["Agent: exec('gh repo list --json name')"] --> B["shell.go Execute()"]
    B --> C{"parseCommandBinary()"}
    C --> D{"LookupByBinary()"}
    D -->|"Not found"| E["Normal exec\n(sh -c, unchanged)"]
    D -->|"Found"| F["credentialed_exec.go"]

    F --> G{"detectShellOperators()"}
    G -->|"Found ; && | etc"| H["❌ Structured error"]
    G -->|"Clean"| I{"resolveAndMatchBinary()"}
    I -->|"Path mismatch"| J["❌ No credentials"]
    I -->|"Match ✅"| K{"matchesBinaryDeny()"}
    K -->|"Blocked"| L["❌ Deny error"]
    K -->|"Allowed"| M["Decrypt credentials\n(AES-256-GCM)"]
    M --> N["AddCredentialScrubValues()"]
    N --> O{"Sandbox?"}
    O -->|"No"| P["exec.Command(absPath, args...)\nenv=[CRED=xxx, PATH, HOME]"]
    O -->|"Yes"| Q["docker exec -e CRED=xxx\ncontainerID absPath args..."]
    P --> R["ScrubCredentials()"]
    Q --> R
    R --> S["✅ Clean output to agent"]

    style H fill:#fee,stroke:#f66
    style J fill:#fee,stroke:#f66
    style L fill:#fee,stroke:#f66
    style S fill:#efe,stroke:#6b6

Key principle: When credentials are present, commands run via exec.Command(binary, args...) — no shell (sh -c). This eliminates shell injection entirely because ;, &&, |, $(), backticks have no special meaning without a shell interpreter.


2. Security Model: Defense-in-Depth

Four independent layers protect credentials. Even if one layer is bypassed, remaining layers continue to protect.

flowchart LR
    subgraph "Layer 1: No Shell"
        L1["exec.Command()\nNo sh -c"]
    end
    subgraph "Layer 2: Validation"
        L2a["Binary path\nverification"]
        L2b["Per-binary\ndeny patterns"]
        L2c["Shell operator\ndetection"]
    end
    subgraph "Layer 3: Output"
        L3a["Static regex\nscrubbing"]
        L3b["Credential value\nscrubbing"]
    end
    subgraph "Layer 4: Isolation"
        L4a["Docker sandbox"]
        L4b["PID namespace"]
        L4c["cap-drop ALL"]
    end
    L1 --> L2a --> L3a --> L4a

Edge Cases Analyzed (13 total)

# Edge Case Severity Mitigation
1 Shell command chaining (; && || |) 🔴 CRITICAL Direct Exec Mode — no shell interpreter
2 Lost shell features (pipes, redirects) 🟡 Structured output flags (--json) + separate calls
3 Binary name spoofing (./gh) 🟡 Absolute path resolution via exec.LookPath + config match
4 Dynamic binary resolution ($(which gh)) 🟢 Blocked by Direct Exec — $() is literal
5 Multiple binaries (gh && curl) 🟢 Blocked by Direct Exec — && is literal
6 /proc/PID/environ cross-read 🟡 Docker PID namespace + deny pattern
7 Debug/verbose output leaking creds 🟡 Per-binary deny_verbose patterns + scrubbing
8 Temp file credential exposure 🟡 Pipe fd injection (Linux), temp file (Windows)
9 strace/ltrace/gdb 🟢 Docker cap-drop ALL + deny patterns
10 CLI config file access 🟢 PathDenyable + Docker sandbox
11 Multi-tenant scope mismatch 🟡 Per-agent + global DB scoping with priority
12 Binary not found 🟢 Startup validation via exec.LookPath
13 Credential rotation window 🟢 Short-lived child processes (no caching)

3. Database Schema

Table: secure_cli_binaries (Migration 000019)

CREATE TABLE secure_cli_binaries (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
    binary_name     TEXT NOT NULL,           -- "gh", "gcloud", "aws"
    binary_path     TEXT,                    -- resolved absolute path (nullable)
    description     TEXT NOT NULL DEFAULT '',
    encrypted_env   BYTEA NOT NULL,          -- AES-256-GCM encrypted JSON
    deny_args       JSONB NOT NULL DEFAULT '[]',    -- regex deny patterns
    deny_verbose    JSONB NOT NULL DEFAULT '[]',    -- verbose flag patterns
    timeout_seconds INTEGER NOT NULL DEFAULT 30,
    tips            TEXT NOT NULL DEFAULT '',
    agent_id        UUID REFERENCES agents(id) ON DELETE CASCADE,
    enabled         BOOLEAN NOT NULL DEFAULT true,
    created_by      TEXT NOT NULL DEFAULT '',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

Scoping: agent_id = NULL means global (all agents). Agent-specific configs take priority over global via ORDER BY agent_id NULLS LAST LIMIT 1.

Encryption: encrypted_env stores AES-256-GCM encrypted JSON (e.g., {"GH_TOKEN":"ghp_xxx"}). Encrypted by PGSecureCLIStore on write, decrypted on read. Uses aes-gcm: prefix convention matching llm_providers.


4. Store Layer

Interface (internal/store/secure_cli_store.go)

type SecureCLIStore interface {
    Create(ctx, *SecureCLIBinary) error
    Get(ctx, id) (*SecureCLIBinary, error)
    Update(ctx, id, map[string]any) error
    Delete(ctx, id) error
    List(ctx) ([]SecureCLIBinary, error)
    ListByAgent(ctx, agentID) ([]SecureCLIBinary, error)
    LookupByBinary(ctx, binaryName, *agentID) (*SecureCLIBinary, error)  // priority lookup
    ListEnabled(ctx) ([]SecureCLIBinary, error)  // for TOOLS.md context
}

Lookup Priority

LookupByBinary returns the best match:

  1. Agent-specific config (exact agent_id match)
  2. Global config (agent_id IS NULL)
  3. nil if no match

5. Direct Exec Engine

Core File: internal/tools/credentialed_exec.go

Command Parsing

Uses github.com/mattn/go-shellwords for proper shell-word tokenization:

parseCommandBinary("gh issue create --title \"Fix the bug\"")
→ binary: "gh", args: ["issue", "create", "--title", "Fix the bug"]

Handles: quoted strings, escaped characters, = signs, empty strings.

Shell Operator Detection

Regex detects metacharacters before execution:

var shellOperatorPattern = regexp.MustCompile(`[;|&<>\n\r` + "`" + `]|\$\(|\$\{`)

Detected operators: ; | & < > ` \n \r $( ${

Returns structured error with clear guidance:

[CREDENTIALED EXEC] Shell operators not supported.
Detected: ;
This CLI runs in Direct Exec Mode — no shell operators.
Run the command without operators. Use --json for structured output.

Binary Path Verification

resolveAndMatchBinary("gh", configPath)
  → exec.LookPath("gh") → "/usr/bin/gh"
  → if configPath != nil && "/usr/bin/gh" != configPath → error
  → return "/usr/bin/gh"

Prevents binary spoofing: ./gh (workspace) resolves to different path than /usr/bin/gh (system).

Per-Binary Deny Patterns

Args joined as string, matched against regex patterns from deny_args and deny_verbose:

// deny_args: ["auth\\s+", "ssh-key", "repo\\s+delete"]
matchesBinaryDeny(["auth", "login"], denyArgs) → "auth\\s+" (blocked)
matchesBinaryDeny(["repo", "list"],  denyArgs) → "" (allowed)

Execution Paths

Host mode:

cmd := exec.Command(absPath, args...)
cmd.Env = [PATH=..., HOME=..., LANG=..., USER=..., GH_TOKEN=xxx]
cmd.Dir = workspace

Sandbox mode:

sb.Exec(ctx, []string{absPath, args...}, cwd, sandbox.WithEnv(envMap))
// → docker exec -e GH_TOKEN=xxx containerID /usr/bin/gh repo list --json name

Approval Bypass

Credentialed binaries auto-bypass ExecApprovalManager. Rationale: admin configuring credentials = implicit approval for that binary. Lookup happens BEFORE approval check in shell.go Execute().


6. Credential Scrubbing

Two-tier scrubbing via internal/tools/scrub.go:

Static Patterns (11 regexes)

Pre-compiled patterns for known credential formats: sk-*, ghp_*, AKIA*, connection strings, etc.

Dynamic Credential Values

AddCredentialScrubValues("ghp_xxxx...")  // registered on decrypt

Values replaced with [REDACTED] in all tool output (both ForLLM and ForUser). Thread-safe, deduplicated, minimum length 6 to avoid false positives.


7. TOOLS.md Context Injection

GenerateCredentialContext() (credential_context.go) builds a system prompt supplement appended after the ## Tooling section. This tells the LLM:

  • Which CLIs are available with pre-configured auth
  • That these CLIs run in Direct Exec Mode (no shell operators)
  • Which operations are blocked per CLI
  • How to handle blocked operations
## Credentialed CLI Tools

The following CLI tools have pre-configured authentication.
Credentials are injected automatically — do NOT attempt to provide or read credentials.

⚠️ CRITICAL: These tools run in DIRECT EXEC MODE (no shell).
- Do NOT use shell operators: ;  &&  ||  |  >  >>  <  $()  ``
- Each exec() call runs ONE command only
- Use --json or --format=json for structured output

### Available CLIs:

**gh** — GitHub CLI
  Blocked: auth, ssh-key, gpg-key, repo delete, secret
  Tip: Use --json flag for structured output

UX impact: Eliminates agent retry loops caused by mode confusion. Agent knows to use --json flags and avoid pipes.


8. Built-in CLI Presets

5 presets in credential_presets.go — auto-fill env vars, deny patterns, timeout, tips:

Preset Env Vars Deny Patterns Timeout
gh GH_TOKEN auth\s+, ssh-key, gpg-key, repo\s+delete, secret\s+ 30s
gcloud GOOGLE_APPLICATION_CREDENTIALS (file) iam\s+, auth\s+, projects\s+delete, services\s+disable, kms\s+ 120s
aws AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION (opt) iam\s+, organizations\s+, sts\s+assume, ec2\s+terminate 60s
kubectl KUBECONFIG (file) delete\s+namespace, delete\s+node, drain\s+, cordon\s+ 60s
terraform TF_TOKEN_app_terraform_io (opt) destroy, force-unlock 300s

API: POST /v1/cli-credentials {"preset": "gh", "env": {"GH_TOKEN": "ghp_xxx"}} auto-fills all preset fields.


9. HTTP API

Method Path Description
GET /v1/cli-credentials List all (env masked)
POST /v1/cli-credentials Create (supports preset field)
GET /v1/cli-credentials/presets List available presets
GET /v1/cli-credentials/{id} Get single (env masked)
PUT /v1/cli-credentials/{id} Update (field allowlisted)
DELETE /v1/cli-credentials/{id} Delete
POST /v1/cli-credentials/{id}/test Dry run commands against deny patterns

Security: Credentials (encrypted_env) are NEVER returned in API responses. EncryptedEnv = nil set explicitly in all GET handlers. Update handler uses field allowlist to prevent column injection.

Dry Run

Test commands against deny patterns before deploying:

POST /v1/cli-credentials/{id}/test
{"test_commands": ["gh repo list", "gh auth login", "gh repo delete x"]}

→ {"results": [
    {"command": "gh repo list",     "allowed": true,  "matched_deny": null},
    {"command": "gh auth login",    "allowed": false, "matched_deny": "auth\\s+"},
    {"command": "gh repo delete x", "allowed": false, "matched_deny": "repo\\s+delete"}
  ]}

10. Sandbox Integration

ExecOption Pattern

sandbox.Exec() extended with variadic options:

type ExecOption func(*ExecOpts)
func WithEnv(env map[string]string) ExecOption

// Usage:
sb.Exec(ctx, []string{"/usr/bin/gh", "repo", "list"}, "/workspace",
    sandbox.WithEnv(map[string]string{"GH_TOKEN": "ghp_xxx"}))

// Translates to:
// docker exec -e GH_TOKEN=ghp_xxx -w /workspace <containerID> /usr/bin/gh repo list

Important: Env vars injected via docker exec -e are NOT accessible to other processes in the container (unlike container-level env vars). This provides per-command isolation.


11. Web UI

Page: ui/web/src/pages/cli-credentials/

Features:

  • CRUD table with binary name, description, enabled badge, agent scope, timeout
  • Form dialog with preset selector dropdown
  • When preset selected → auto-fills all fields, shows env var inputs (password type)
  • Dry run panel for testing commands against deny patterns
  • Sidebar navigation under System group with KeyRound icon

12. Structured Error Messages

Three error types returned to the LLM with clear context:

Error Type Trigger LLM Guidance
Shell operator ;, |, &&, etc. detected "Remove operators, use --json"
Deny pattern Command matches deny_args/deny_verbose "Requires admin approval"
Exec failure Non-zero exit code "Direct Exec Mode, no shell operators"

All errors include [CREDENTIALED EXEC] prefix for LLM pattern recognition. ForUser field provides concise user-facing message.


13. File Reference

File Lines Purpose
migrations/000019_secure_cli_binaries.up.sql 24 Database table + indexes
internal/store/secure_cli_store.go 45 Store interface + SecureCLIBinary type
internal/store/pg/secure_cli.go 210 PostgreSQL CRUD + LookupByBinary
internal/tools/credentialed_exec.go 260 Direct Exec engine: parse, validate, execute
internal/tools/credential_presets.go 100 5 CLI presets: gh, gcloud, aws, kubectl, terraform
internal/tools/credential_context.go 70 TOOLS.md context generator
internal/tools/scrub.go 120 Credential scrubbing (AddCredentialScrubValues)
internal/http/secure_cli.go 270 HTTP CRUD + presets + dry run
internal/sandbox/sandbox.go — ExecOption, WithEnv()
internal/sandbox/docker.go — docker exec -e env injection
internal/agent/systemprompt.go — CredentialCLIContext injection

Cross-References

Document Relevant Content
03-tools-system.md Credentialed CLI Tools section under Shell Exec
09-security.md Credentialed Exec under Layer 3: Tool Security
06-store-data-model.md Store interface pattern
17-changelog.md Feature entry