mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-21 03:05:44 +00:00
06fd4fc89e
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.
171 lines
4.6 KiB
Go
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
|
|
}
|