Files
pulse/internal/ai/discovery_adapter.go
T
rcourtman 06fd4fc89e Bypass approval gate for trusted internal Discovery commands
Discovery wraps every probe in `docker exec <container> sh -c '...'`.
The agentexec command policy lists `^docker\s+exec\s` as RequireApproval
(a sound default for user-driven docker exec) and Discovery has no path
to mint or supply an ApprovalID. Result: every probe was rejected, the
scanner returned empty CommandOutputs, and the AI fell back to
"Unknown Infrastructure Resource" at confidence 0. The Discovery sub-tab
rendered empty after a "successful" run.

Add a Trusted bool to ExecuteCommandPayload on both the server-facing
agentexec type and the agent's wire struct. When set, the approval gate
is skipped on both ends and the server does not attempt to auto-mint an
approval grant (which would fail with "approval id is required").
PolicyBlock still applies; this is not a way to run arbitrary commands.

Only the discoveryCommandAdapter sets Trusted=true. The flag is never
populated from a deserialised HTTP body or any user-driven path. Patrol
fixes, Assistant remediation, and AI tool calls continue to flow through
the governed approval-record path with a real ApprovalID.

Contracts: amend agent-lifecycle Completion Obligations and Current
State to document the lone exception to the on-agent approval rail, and
amend ai-runtime to fence the Trusted flag to the discovery adapter
only.
2026-05-17 21:59:39 +01:00

171 lines
4.6 KiB
Go

package ai
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery"
)
const (
defaultDiscoveryCommandTimeoutSeconds = 60
minDiscoveryCommandTimeoutSeconds = 1
)
// discoveryCommandAdapter adapts agentexec.Server to servicediscovery.CommandExecutor
type discoveryCommandAdapter struct {
server *agentexec.Server
}
// newDiscoveryCommandAdapter creates a new adapter
func newDiscoveryCommandAdapter(server *agentexec.Server) *discoveryCommandAdapter {
return &discoveryCommandAdapter{server: server}
}
// ExecuteCommand implements servicediscovery.CommandExecutor
func (a *discoveryCommandAdapter) ExecuteCommand(ctx context.Context, agentID string, cmd servicediscovery.ExecuteCommandPayload) (*servicediscovery.CommandResultPayload, error) {
ctx = nonNilContext(ctx)
execCmd := normalizeDiscoveryExecuteCommandPayload(ctx, cmd)
if a.server == nil {
return &servicediscovery.CommandResultPayload{
RequestID: execCmd.RequestID,
Success: false,
Error: "agent server not available",
}, nil
}
result, err := a.server.ExecuteCommand(ctx, agentID, execCmd)
if err != nil {
return &servicediscovery.CommandResultPayload{
RequestID: execCmd.RequestID,
Success: false,
Error: err.Error(),
}, nil
}
if result == nil {
return &servicediscovery.CommandResultPayload{
RequestID: execCmd.RequestID,
Success: false,
Error: "agent server returned no command result",
}, nil
}
resultRequestID := result.RequestID
if strings.TrimSpace(resultRequestID) == "" {
resultRequestID = execCmd.RequestID
}
// Convert result back
return &servicediscovery.CommandResultPayload{
RequestID: resultRequestID,
Success: result.Success,
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
Error: result.Error,
Duration: result.Duration,
}, nil
}
// GetConnectedAgents implements servicediscovery.CommandExecutor
func (a *discoveryCommandAdapter) GetConnectedAgents() []servicediscovery.ConnectedAgent {
if a.server == nil {
return nil
}
agents := a.server.GetConnectedAgents()
result := make([]servicediscovery.ConnectedAgent, len(agents))
for i, agent := range agents {
result[i] = servicediscovery.ConnectedAgent{
AgentID: agent.AgentID,
Hostname: agent.Hostname,
Version: agent.Version,
Platform: agent.Platform,
Tags: cloneStringSlice(agent.Tags),
ConnectedAt: agent.ConnectedAt,
}
}
return result
}
// IsAgentConnected implements servicediscovery.CommandExecutor
func (a *discoveryCommandAdapter) IsAgentConnected(agentID string) bool {
if a.server == nil {
return false
}
return a.server.IsAgentConnected(agentID)
}
func nonNilContext(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
func normalizeDiscoveryExecuteCommandPayload(ctx context.Context, cmd servicediscovery.ExecuteCommandPayload) agentexec.ExecuteCommandPayload {
requestID := strings.TrimSpace(cmd.RequestID)
if requestID == "" {
requestID = uuid.NewString()
}
timeoutSeconds := cmd.Timeout
if timeoutSeconds <= 0 {
timeoutSeconds = defaultDiscoveryCommandTimeoutSeconds
}
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining <= 0 {
timeoutSeconds = minDiscoveryCommandTimeoutSeconds
} else {
maxTimeout := int((remaining + time.Second - 1) / time.Second)
if maxTimeout < minDiscoveryCommandTimeoutSeconds {
maxTimeout = minDiscoveryCommandTimeoutSeconds
}
if timeoutSeconds > maxTimeout {
timeoutSeconds = maxTimeout
}
}
}
return agentexec.ExecuteCommandPayload{
RequestID: requestID,
Command: cmd.Command,
TargetType: cmd.TargetType,
TargetID: cmd.TargetID,
Timeout: timeoutSeconds,
// Discovery probes are a fixed, in-source catalog of read-only
// inspections (cat /etc/os-release, ps, ss, env, find under a few
// known config roots) wrapped in `docker exec <container>` or
// `pct/qm exec`. They never accept user-supplied commands. The
// trusted flag lets them bypass the user-driven approval gate that
// would otherwise block every `docker exec` dispatch.
Trusted: true,
}
}
func cloneStringSlice(values []string) []string {
if len(values) == 0 {
return nil
}
cloned := make([]string, len(values))
copy(cloned, values)
return cloned
}
func cloneStringMap(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}
cloned := make(map[string]string, len(values))
for k, v := range values {
cloned[k] = v
}
return cloned
}