docs(providers): add ACP and credentialed exec references

Refs #189

Refs #197
This commit is contained in:
Duy Nguyen committed 2026-06-21 16:09:37 +07:00
1 parent bd52dbaf6f
commit 2a66401a28
3 files changed
+940

No files matched your search

+477
View File
@@ -0,0 +1,477 @@
# 18 - ACP Provider (Agent Client Protocol)
The ACP provider enables GoClaw to orchestrate external coding agents (Claude Code, Codex CLI, Gemini CLI, Kiro, or any ACP-compatible agent) as subprocesses via JSON-RPC 2.0 over stdio. One provider covers all ACP agents through config-driven agent registry.
> **References:** [ACP Spec](https://agentclientprotocol.com/) · [ACP Schema](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/schema/schema.json) · Issue [#189](https://github.com/nextlevelbuilder/goclaw/issues/189) · PR [#190](https://github.com/nextlevelbuilder/goclaw/pull/190)
---
## 1. Architecture
```mermaid
flowchart TD
AL["GoClaw Agent Loop"] -->|"Chat / ChatStream"| ACP["ACPProvider<br/>(acp_provider.go)"]
ACP -->|"GetOrSpawn"| PP["ProcessPool<br/>(process.go)"]
PP -->|"spawn binary"| PROC["Subprocess<br/>(stdin/stdout pipes)"]
PROC <-->|"JSON-RPC 2.0<br/>newline-delimited"| CONN["Conn<br/>(jsonrpc.go)"]
CONN -->|"initialize"| AGT["Agent<br/>(claude, codex, gemini...)"]
CONN -->|"session/new"| AGT
CONN -->|"session/prompt"| AGT
CONN -->|"session/cancel"| AGT
AGT -->|"fs/readTextFile"| TB["ToolBridge<br/>(tool_bridge.go)"]
AGT -->|"fs/writeTextFile"| TB
AGT -->|"terminal/*"| TERM["Terminal Registry<br/>(terminal.go)"]
AGT -->|"permission/request"| TB
TB -->|"workspace sandbox"| FS["Filesystem"]
TERM -->|"deny patterns + allowlist"| CMD["Command Execution"]
```
**Key principles:**
- GoClaw is an ACP **client** — it spawns and controls agent subprocesses
- Each subprocess is a long-lived OS process communicating via stdin/stdout
- Security enforced at the tool bridge layer: workspace sandboxing, deny patterns, permission modes
---
## 2. Wire Protocol: JSON-RPC 2.0 over Stdio
### Transport
Messages are **newline-delimited JSON** on stdin/stdout. Each message is a complete JSON object followed by `\n`. The `Conn` type (`jsonrpc.go`, 217 lines) handles bidirectional communication.
```
GoClaw (Client) Agent (Server)
│ │
│──── {"jsonrpc":"2.0","id":1, ────►│ Request
│ "method":"initialize", │
│ "params":{...}} │
│ │
│◄─── {"jsonrpc":"2.0","id":1, ─────│ Response
│ "result":{...}} │
│ │
│◄─── {"jsonrpc":"2.0", ─────│ Notification (no id)
│ "method":"session/update", │
│ "params":{...}} │
│ │
│◄─── {"jsonrpc":"2.0","id":42, ─────│ Agent→Client Request
│ "method":"fs/readTextFile", │
│ "params":{"path":"..."}} │
│ │
│──── {"jsonrpc":"2.0","id":42, ────►│ Client→Agent Response
│ "result":{"content":"..."}} │
```
### Message Format
```go
type jsonrpcMessage struct {
JSONRPC string `json:"jsonrpc"` // always "2.0"
ID *int64 `json:"id,omitempty"` // present for requests/responses, absent for notifications
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *jsonrpcError `json:"error,omitempty"`
}
```
### Key Conn Methods
| Method | Purpose |
|--------|---------|
| `Call(ctx, method, params, &result)` | Send request, block until response (with context timeout) |
| `Notify(method, params)` | Fire-and-forget notification |
| `Start()` | Spawn `readLoop` goroutine for incoming messages |
| `Done()` | Channel closed when read loop exits (process died) |
**Buffer sizing:** Scanner uses 256KB initial / 10MB max per message — handles large file contents in tool bridge responses.
**ID sequencing:** Atomic `Int64` counter, no lock contention.
---
## 3. Session Lifecycle
```mermaid
sequenceDiagram
participant C as GoClaw (Client)
participant A as Agent (Subprocess)
C->>A: initialize {clientInfo, capabilities}
A->>C: initialize response {agentInfo, capabilities}
C->>A: session/new {}
A->>C: session/new response {sessionId}
loop Per user message
C->>A: session/prompt {sessionId, content}
A-->>C: session/update {kind:"message", content}
A-->>C: session/update {kind:"message", content}
A->>C: session/prompt response {stopReason}
end
Note over C,A: Optional: Cancel
C-->>A: session/cancel {sessionId}
```
### Phase 1: Initialize
Client declares capabilities (filesystem read/write, terminal support). Agent responds with identity and capabilities (audio, image, embedded context).
```go
// Client sends:
InitializeRequest{
ClientInfo: ClientInfo{Name: "goclaw", Version: "1.0"},
Capabilities: ClientCaps{
Fs: &FsCaps{Read: true, Write: true},
Terminal: &TerminalCaps{Create: true},
},
}
```
### Phase 2: New Session
Creates an isolated session on the agent. Returns `sessionId` used in all subsequent prompt calls.
### Phase 3: Prompt Loop
Send user content blocks (text + images). Agent streams `session/update` notifications with message deltas, tool call progress, and plan updates. Prompt completes with a response containing `stopReason`.
### Phase 4: Cancel (Optional)
Cooperative cancellation via `session/cancel` notification. Agent may take time to stop.
---
## 4. Content Handling
### ContentBlock Types
```go
type ContentBlock struct {
Type string `json:"type"` // "text", "image", "audio"
Text string `json:"text,omitempty"` // text content
Data string `json:"data,omitempty"` // base64 for image/audio
MimeType string `json:"mimeType,omitempty"` // e.g. "image/png"
}
```
### Request Extraction (GoClaw → Agent)
1. Extract system prompt + user message from `ChatRequest.Messages`
2. Prepend system prompt to first user message (ACP has no separate system message API)
3. Attach images as separate content blocks with base64 data
### Response Collection (Agent → GoClaw)
1. Accumulate `SessionUpdate` notifications during prompt execution
2. Collect text blocks into response content string
3. Map `stopReason` to GoClaw finish reason:
- `"maxContextLength"` → `"length"`
- All others → `"stop"`
### SessionUpdate Structure
```go
type SessionUpdate struct {
Kind string `json:"kind"` // "message", "toolCall", "plan"
Content []ContentBlock `json:"content,omitempty"`
ToolCall *ToolCallUpdate `json:"toolCall,omitempty"`
}
type ToolCallUpdate struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // "running", "completed"
Content []ContentBlock `json:"content,omitempty"`
}
```
---
## 5. Process Pool
`ProcessPool` (`process.go`, 237 lines) manages subprocess lifecycle.
### Spawn Flow
```
GetOrSpawn(sessionKey)
├→ Check cached process (sync.Map)
│ └→ Found + alive → return
├→ Acquire per-key spawn mutex (prevent thundering herd)
└→ spawn():
├→ exec.Command(binary, args...)
├→ cmd.Env = filterACPEnv(os.Environ()) // strip secrets
├→ Create stdin/stdout pipes
├→ cmd.Stderr = limitedWriter(4KB)
├→ cmd.Start()
├→ NewConn(stdin, stdout, toolBridge.Handle, notifyHandler)
├→ conn.Start() // begin readLoop
├→ Initialize() // ACP handshake
├→ NewSession() // create session
├→ Monitor exit in background goroutine
└→ Store in pool
```
### Idle Reaping
Every 30 seconds, the reaper checks all processes:
```go
for each process in pool:
if process.inUse > 0: skip // active prompt running
if time.Since(lastActive) > idleTTL:
process.cmd.Process.Kill() // SIGKILL
remove from pool
```
### Crash Recovery
If a process exits unexpectedly (detected via `<-proc.exited` channel), the next `GetOrSpawn` call automatically spawns a replacement. The active prompt is lost — caller receives an error.
### Concurrency Controls
| Mechanism | Purpose |
|-----------|---------|
| `sync.Map` for processes | Lock-free concurrent access |
| Per-key spawn mutex | Prevent duplicate spawns for same session |
| `inUse` atomic flag | Reaper skips active processes |
| `lastActive` timestamp | Tracks idle time for reaping |
| Session-level mutex in ACPProvider | Serializes prompts per session |
---
## 6. Tool Bridge (Agent → Client Requests)
`ToolBridge` (`tool_bridge.go`, 204 lines) handles all agent-initiated requests with security enforcement.
### Request Routing
| Method | Handler | Description |
|--------|---------|-------------|
| `fs/readTextFile` | `readFile()` | Read file within workspace |
| `fs/writeTextFile` | `writeFile()` | Write file within workspace |
| `terminal/createTerminal` | `createTerminal()` | Spawn command subprocess |
| `terminal/terminalOutput` | `terminalOutput()` | Get current output |
| `terminal/waitForTerminalExit` | `waitForExit()` | Block until exit (10-min timeout) |
| `terminal/releaseTerminal` | `releaseTerminal()` | Clean up resources |
| `terminal/killTerminal` | `killTerminal()` | Force-terminate |
| `permission/request` | `handlePermission()` | Permission check |
### Permission Modes
| Mode | Reads | Writes | Terminal | Permission Requests |
|------|-------|--------|----------|-------------------|
| `approve-all` | ✅ | ✅ | ✅ | ✅ (default) |
| `approve-reads` | ✅ | ❌ | ❌ | Per-type |
| `deny-all` | ❌ | ❌ | ❌ | ❌ |
### Workspace Sandbox
All file paths validated via `resolvePath()`:
```go
func resolvePath(path string) (string, error) {
abs := filepath.Join(workspace, path)
real, _ := filepath.EvalSymlinks(abs) // resolve symlinks
if !strings.HasPrefix(real, workspace) {
slog.Warn("security.acp_path_escape", ...)
return "", fmt.Errorf("path outside workspace")
}
return real, nil
}
```
Symlink resolution prevents `../../etc/passwd` attacks even when symlinks point outside workspace.
---
## 7. Terminal System
`Terminal` (`terminal.go`, 212 lines) manages command execution within the tool bridge.
### Security Layers
**1. Binary Allowlist (63 binaries):**
```
sh, bash, zsh, fish, node, npm, npx, pnpm, yarn, bun, deno,
python, python3, pip, pip3, uv, ruby, gem, go, cargo, rustc,
java, javac, mvn, gradle, dotnet, git, gh, docker, kubectl,
make, cmake, gcc, g++, clang, curl, wget, jq, yq, tar, zip,
unzip, gzip, cat, head, tail, less, grep, rg, find, ls, mv,
cp, mkdir, rm, chmod, touch, sed, awk, sort, wc, diff, tee
```
**2. Deny Patterns:** Regex patterns from GoClaw's `DefaultDenyPatterns` are applied to the full command string (binary + args).
**3. Working Directory Sandbox:** Terminal `cwd` validated against workspace boundary.
### cappedBuffer
Thread-safe circular buffer that retains only the last N bytes (default 10MB):
```go
type cappedBuffer struct {
mu sync.Mutex
data []byte
max int
}
// On overflow: keeps tail (recent output), discards head
```
Used for both stdout and stderr capture. Prevents unbounded memory growth from verbose agent output.
---
## 8. Environment Filtering
Before spawning any agent subprocess, `filterACPEnv()` strips sensitive environment variables:
**Prefix-based (12 prefixes):**
```
GOCLAW_, CLAUDE_, ANTHROPIC_, OPENAI_, DATABASE_, AWS_,
GOOGLE_, AZURE_, GITHUB_, DOCKER_, STRIPE_, SSH_
```
**Exact-match (15 keys):**
```
DB_DSN, PGPASSWORD, PGUSER, PGHOST, PGDATABASE, PGPORT,
REDIS_URL, MONGO_URI, NPM_TOKEN, SENTRY_AUTH_TOKEN,
SENTRY_DSN, DATADOG_API_KEY, TWILIO_AUTH_TOKEN,
SENDGRID_API_KEY, SLACK_TOKEN
```
This prevents credential leakage to untrusted agent binaries.
---
## 9. Configuration
### Config File (config.json)
```json5
{
"providers": {
"acp": {
"binary": "claude", // agent binary (must be in PATH)
"args": ["--profile", "goclaw"], // optional spawn args
"model": "claude", // default model name for routing
"work_dir": "/workspace", // base workspace directory
"idle_ttl": "5m", // process idle timeout
"perm_mode": "approve-all" // "approve-all" | "approve-reads" | "deny-all"
}
}
}
```
### Database Registration
Create via Providers API or Web UI:
| Field | Value |
|-------|-------|
| `provider_type` | `"acp"` |
| `api_base` | Binary name or absolute path (`"claude"`, `"/usr/local/bin/codex"`) |
| `settings` | `{"args": [...], "idle_ttl": "5m", "perm_mode": "approve-all", "work_dir": "..."}` |
Binary validation: Only `claude`, `codex`, `gemini`, or absolute paths are accepted for DB-based registration. Verified via `exec.LookPath()`.
### Gateway Wiring
```go
// Config-based: resolved at startup
registerACPFromConfig(registry, cfg.Providers.ACP)
// DB-based: resolved from llm_providers table
registerACPFromDB(registry, providerData)
```
Both paths:
1. Verify binary exists via `exec.LookPath`
2. Parse `IdleTTL` duration
3. Resolve `WorkDir` (default: `~/.goclaw/acp-workspaces`)
4. Create `NewACPProvider(binary, args, workDir, idleTTL, denyPatterns, opts...)`
### Live Reload
DB-based providers support live reload via pubsub. When a provider is created/updated/deleted in the Web UI, a `cache.invalidate` event triggers re-registration without gateway restart.
---
## 10. Streaming vs Non-Streaming
### Chat (Non-Streaming)
```go
func (p *ACPProvider) Chat(ctx, req) → *ChatResponse
```
1. Lock session mutex
2. `GetOrSpawn` process
3. `Prompt(content, onUpdate)` — blocks until complete
4. Collect all text deltas into `strings.Builder`
5. Return `ChatResponse{Content: text, FinishReason: mapped}`
### ChatStream
```go
func (p *ACPProvider) ChatStream(ctx, req, onChunk) → *ChatResponse
```
1. Lock session mutex
2. Set up cancel listener (`session/cancel` on context cancellation)
3. `GetOrSpawn` process
4. `Prompt(content, onUpdate)` with callback:
- Extract text blocks from each `SessionUpdate`
- Emit `StreamChunk{Content: delta}` via `onChunk`
5. On completion: emit `StreamChunk{Done: true}`
6. Return accumulated `ChatResponse`
---
## 11. Error Handling
| Scenario | Behavior |
|----------|----------|
| Binary not found | Log warning, skip provider registration |
| Subprocess crash mid-prompt | Active prompt fails; next `GetOrSpawn` respawns |
| Malformed JSON-RPC | Log debug, skip message, continue reading |
| Path escape attempt | Log `security.acp_path_escape`, return error to agent |
| Terminal binary not in allowlist | Return error to agent |
| Terminal deny pattern match | Return error to agent |
| Context cancelled (ChatStream) | Send `session/cancel`, return partial response |
| Idle timeout | Reaper kills process; respawned on next request |
| Permission denied | Return error based on `perm_mode` |
| Large output (>10MB terminal) | cappedBuffer retains tail only |
---
## 12. File Reference
| File | Lines | Purpose |
|------|-------|---------|
| `internal/providers/acp_provider.go` | 227 | Provider interface: Chat, ChatStream, content extraction |
| `internal/providers/acp/types.go` | 189 | ACP protocol types: Initialize, Session, ContentBlock |
| `internal/providers/acp/jsonrpc.go` | 217 | Bidirectional JSON-RPC 2.0 over stdio |
| `internal/providers/acp/process.go` | 237 | Subprocess pool: spawn, reap, crash recovery |
| `internal/providers/acp/session.go` | 71 | Session lifecycle: init → new → prompt → cancel |
| `internal/providers/acp/tool_bridge.go` | 204 | Agent→client request handler with sandbox |
| `internal/providers/acp/terminal.go` | 212 | Terminal subprocess lifecycle + cappedBuffer |
| `internal/providers/acp/helpers.go` | 81 | Environment filtering, limitedWriter |
| `internal/config/config_channels.go` | — | `ACPConfig` struct definition |
| `internal/store/provider_store.go` | — | `ProviderACP = "acp"` constant |
| `cmd/gateway_providers.go` | — | Config + DB registration wiring |
---
## Cross-References
| Document | Relevant Content |
|----------|-----------------|
| [02-providers.md](./02-providers.md) | ACP overview section (§10) |
| [03-tools-system.md](./03-tools-system.md) | Shell deny patterns reused by ToolBridge |
| [09-security.md](./09-security.md) | Defense-in-depth layers |
| [01-agent-loop.md](./01-agent-loop.md) | Chat/ChatStream provider contract |
+388
View File
@@ -0,0 +1,388 @@
# 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](https://github.com/nextlevelbuilder/goclaw/issues/197) · PR [#199](https://github.com/nextlevelbuilder/goclaw/pull/199)
---
## 1. Architecture
```mermaid
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.
```mermaid
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)
```sql
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`)
```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:
```go
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:
```go
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
```go
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`:
```go
// 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:**
```go
cmd := exec.Command(absPath, args...)
cmd.Env = [PATH=..., HOME=..., LANG=..., USER=..., GH_TOKEN=xxx]
cmd.Dir = workspace
```
**Sandbox mode:**
```go
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
```go
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
```markdown
## 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:
```json
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:
```go
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](./03-tools-system.md) | Credentialed CLI Tools section under Shell Exec |
| [09-security.md](./09-security.md) | Credentialed Exec under Layer 3: Tool Security |
| [06-store-data-model.md](./06-store-data-model.md) | Store interface pattern |
| [17-changelog.md](./17-changelog.md) | Feature entry |
@@ -0,0 +1,75 @@
# Credentialed Exec Feature: How We Nearly Shipped With Sandbox Blindness
**Date**: 2026-03-14 14:02
**Severity**: High (caught in implementation phase)
**Component**: Shell execution, sandbox mode, credential handling
**Status**: Resolved
## What Happened
Implemented the Credentialed Exec feature (GH-197) allowing authenticated users to execute binaries with stored credentials. The feature shipped in ~1 hour with full test coverage and code review. But we almost missed a critical gap: the original requirements report failed to address sandbox mode's identical vulnerability to direct exec mode.
## The Brutal Truth
This is exactly how security bugs survive: requirements look complete, design review approves them, and implementation teams optimize for speed. We got lucky because we launched aggressive gap analysis before touching code. If we'd followed the original report blindly, production would ship with two injection vectors instead of one. That's the kind of mistake that keeps me up at night—not because we made it, but because it's so easy to make.
## Technical Details
### The Gap (GAP 1 - CRITICAL)
Original report addressed shell injection in **direct exec mode** (`sh -c "$cmd"`) but was **silent on sandbox mode**. Sandbox also uses shell:
```go
docker exec CONTAINER sh -c "command here"
```
This means credentials could be injected via the same shell metacharacter attack. The fix: apply the same Direct Exec pattern to sandbox by passing arguments as env vars via `docker exec -e CRED=value`, bypassing the shell entirely.
### Secondary Gaps
**GAP 2**: Windows compatibility wasn't mentioned. Shell syntax differs; `sh -c` doesn't exist on Windows. Solution: detect OS, use native shell (cmd.exe, PowerShell, sh).
**GAP 3**: Argument parsing. If a credential contains escaped quotes, naive shell-word splitting breaks. Implemented: `go-shellwords` library for robust tokenization.
## What We Tried
1. **Read the final report** → Found it incomplete (3 critical gaps identified)
2. **Launched parallel scouts** → Explored shell.go, sandbox impl, store patterns
3. **Created 7-phase plan** → Planned fixes before implementation
4. **Code review cycle** → 8.5/10; caught 3 issues (scrub values, field allowlist, preset sorting)
5. **All tests passed** → Build, vet, race detector clean
## Root Cause Analysis
The requirements report was written by analyzing static code patterns without dynamic execution context. It answered "how is shell.go currently used?" but didn't ask "where else could this vulnerability exist?" This is a classic requirements failure: domain knowledge gap + checklist-driven thinking = incomplete threat model.
Also: the team optimized for velocity. Nobody said "let's pause and audit the sandbox implementation"—it took explicit gap analysis tasks to surface the issue. Good thing we built that friction point into the workflow.
## Lessons Learned
1. **Requirements aren't designs.** A completed requirements doc doesn't mean you've found all the issues. Treat requirement completeness as a working hypothesis, not ground truth.
2. **Scout parallel gaps early.** We spawned 3 scouts in 5 minutes to explore different code areas. Cost: nothing. Benefit: surfaced sandbox gap before any implementation. This pattern works.
3. **Ask "where else?"** After identifying a vulnerability class (shell injection), systematically hunt for all code paths using that pattern. Don't assume the requirements author did this.
4. **Separate concerns ruthlessly.** Putting credentialed exec logic in a separate `credentialed_exec.go` file instead of patching `shell.go` made the fix self-documenting. Future developers can see at a glance: "oh, this binary has credentials, so it uses Direct Exec mode."
5. **Direct Exec beats sanitization.** We could've tried to escape shell metacharacters (fragile, OS-specific). Instead, we bypassed the shell entirely. This is a general principle: remove the dangerous component rather than trying to use it safely.
## Metrics
- **Feature completeness**: 7/7 phases ✓
- **Code review**: 8.5/10 (3 medium issues fixed)
- **Test coverage**: all paths covered
- **Security issues found**: 1 critical, 3 medium (all fixed)
- **Build status**: green (Go + React)
- **Files changed**: 38 files, ~2000 lines
- **Time-to-ship**: ~1 hour (brainstorm + plan + impl + review)
## Next Steps
1. Merge branch `feat/197-credentialed-exec` to main
2. Update `docs/project-changelog.md` with feature entry
3. Update `docs/development-roadmap.md` progress
4. Post-implementation: monitor usage patterns in production for any edge cases we missed
---
**Key Decision**: Direct Exec mode (bypass shell) + env-var injection in sandbox = eliminates the injection vector entirely rather than trying to sanitize shell input. Credentialed binaries auto-bypass approval flow because they're trusted by definition.