Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions audit/multi_auditor.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func (m *MultiAuditor) AuditRequest(req Request) {
// provided configuration. It always includes a LogAuditor for stderr logging,
// and conditionally adds a SocketAuditor if audit logs are enabled and the
// workspace agent's log proxy socket exists.
func SetupAuditor(ctx context.Context, logger *slog.Logger, disableAuditLogs bool, logProxySocketPath string, sessionID uuid.UUID) (Auditor, error) {
func SetupAuditor(ctx context.Context, logger *slog.Logger, disableAuditLogs bool, logProxySocketPath string, sessionID uuid.UUID, confinedProcessName string) (Auditor, error) {
stderrAuditor := NewLogAuditor(logger)
auditors := []Auditor{stderrAuditor}

Expand All @@ -50,7 +50,7 @@ func SetupAuditor(ctx context.Context, logger *slog.Logger, disableAuditLogs boo
}
agentWillProxy := !os.IsNotExist(err)
if agentWillProxy {
socketAuditor := NewSocketAuditor(logger, logProxySocketPath, sessionID)
socketAuditor := NewSocketAuditor(logger, logProxySocketPath, sessionID, confinedProcessName)
go socketAuditor.Loop(ctx)
auditors = append(auditors, socketAuditor)
} else {
Expand Down
8 changes: 4 additions & 4 deletions audit/multi_auditor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestSetupAuditor_DisabledAuditLogs(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := context.Background()

auditor, err := SetupAuditor(ctx, logger, true, "", uuid.New())
auditor, err := SetupAuditor(ctx, logger, true, "", uuid.New(), "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -52,7 +52,7 @@ func TestSetupAuditor_EmptySocketPath(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := context.Background()

_, err := SetupAuditor(ctx, logger, false, "", uuid.New())
_, err := SetupAuditor(ctx, logger, false, "", uuid.New(), "")
if err == nil {
t.Fatal("expected error for empty socket path, got nil")
}
Expand All @@ -64,7 +64,7 @@ func TestSetupAuditor_SocketDoesNotExist(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := context.Background()

auditor, err := SetupAuditor(ctx, logger, false, "/nonexistent/socket/path", uuid.New())
auditor, err := SetupAuditor(ctx, logger, false, "/nonexistent/socket/path", uuid.New(), "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -102,7 +102,7 @@ func TestSetupAuditor_SocketExists(t *testing.T) {
t.Fatalf("failed to close temp file: %v", err)
}

auditor, err := SetupAuditor(ctx, logger, false, socketPath, uuid.New())
auditor, err := SetupAuditor(ctx, logger, false, socketPath, uuid.New(), "claude")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down
39 changes: 21 additions & 18 deletions audit/socket_auditor.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ const (
// as a FIFO i.e., logs are sent in the order they are received and dropped
// if the queue is full.
type SocketAuditor struct {
dial func() (net.Conn, error)
logger *slog.Logger
logCh chan *agentproto.BoundaryLog
batchSize int
batchTimerDuration time.Duration
socketPath string
sessionID uuid.UUID
dial func() (net.Conn, error)
logger *slog.Logger
logCh chan *agentproto.BoundaryLog
batchSize int
batchTimerDuration time.Duration
socketPath string
sessionID uuid.UUID
confinedProcessName string

droppedChannelFull atomic.Int64
droppedBatchFull atomic.Int64
Expand All @@ -47,7 +48,7 @@ type SocketAuditor struct {
// NewSocketAuditor creates a new SocketAuditor that sends logs to the agent's
// boundary log proxy socket after SocketAuditor.Loop is called. The socket path
// is read from EnvAuditSocketPath, falling back to defaultAuditSocketPath.
func NewSocketAuditor(logger *slog.Logger, socketPath string, sessionID uuid.UUID) *SocketAuditor {
func NewSocketAuditor(logger *slog.Logger, socketPath string, sessionID uuid.UUID, confinedProcessName string) *SocketAuditor {
// This channel buffer size intends to allow enough buffering for bursty
// AI agent network requests while a batch is being sent to the workspace
// agent.
Expand All @@ -57,12 +58,13 @@ func NewSocketAuditor(logger *slog.Logger, socketPath string, sessionID uuid.UUI
dial: func() (net.Conn, error) {
return net.Dial("unix", socketPath)
},
logger: logger,
logCh: make(chan *agentproto.BoundaryLog, logChBufSize),
batchSize: defaultBatchSize,
batchTimerDuration: defaultBatchTimerDuration,
socketPath: socketPath,
sessionID: sessionID,
logger: logger,
logCh: make(chan *agentproto.BoundaryLog, logChBufSize),
batchSize: defaultBatchSize,
batchTimerDuration: defaultBatchTimerDuration,
socketPath: socketPath,
sessionID: sessionID,
confinedProcessName: confinedProcessName,
}
}

Expand Down Expand Up @@ -104,16 +106,17 @@ type flushErr struct {
func (e *flushErr) Error() string { return e.err.Error() }

// flush sends the current batch of logs to the given connection.
func flush(conn net.Conn, sessionID uuid.UUID, logs []*agentproto.BoundaryLog) *flushErr {
func flush(conn net.Conn, sessionID uuid.UUID, confinedProcessName string, logs []*agentproto.BoundaryLog) *flushErr {
if len(logs) == 0 {
return nil
}

msg := &codec.BoundaryMessage{
Msg: &codec.BoundaryMessage_Logs{
Logs: &agentproto.ReportBoundaryLogsRequest{
Logs: logs,
SessionId: sessionID.String(),
Logs: logs,
SessionId: sessionID.String(),
ConfinedProcessName: confinedProcessName,
},
},
}
Expand Down Expand Up @@ -195,7 +198,7 @@ func (s *SocketAuditor) Loop(ctx context.Context) {
return
}

if err := flush(conn, s.sessionID, batch); err != nil {
if err := flush(conn, s.sessionID, s.confinedProcessName, batch); err != nil {
if err.permanent {
// Data error: discard batch to avoid infinite retries.
s.logger.Warn("dropping batch due to data error on flush attempt",
Expand Down
18 changes: 11 additions & 7 deletions audit/socket_auditor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,12 +464,12 @@ func TestSocketAuditor_Loop_ShutdownFlushIncludesDrops(t *testing.T) {
func TestFlush_EmptyBatch(t *testing.T) {
t.Parallel()

err := flush(nil, uuid.Nil, nil)
err := flush(nil, uuid.Nil, "", nil)
if err != nil {
t.Errorf("expected nil error for empty batch, got %v", err)
}

err = flush(nil, uuid.Nil, []*agentproto.BoundaryLog{})
err = flush(nil, uuid.Nil, "", []*agentproto.BoundaryLog{})
if err != nil {
t.Errorf("expected nil error for empty slice, got %v", err)
}
Expand Down Expand Up @@ -516,6 +516,9 @@ func TestSocketAuditor_Loop_FlushIncludesSessionID(t *testing.T) {
if req.SessionId != expectedSessionID {
t.Errorf("expected SessionId=%s, got %q", expectedSessionID, req.SessionId)
}
if req.ConfinedProcessName != "claude" {
t.Errorf("expected ConfinedProcessName=%q, got %q", "claude", req.ConfinedProcessName)
}
if len(req.Logs) != auditor.batchSize {
t.Errorf("expected %d logs, got %d", auditor.batchSize, len(req.Logs))
}
Expand Down Expand Up @@ -572,11 +575,12 @@ func setupTestAuditor(t *testing.T) (*SocketAuditor, net.Conn) {
dial: func() (net.Conn, error) {
return clientConn, nil
},
logger: logger,
logCh: make(chan *agentproto.BoundaryLog, 2*defaultBatchSize),
batchSize: defaultBatchSize,
batchTimerDuration: defaultBatchTimerDuration,
sessionID: uuid.MustParse("00000000-0000-4000-8000-000000000001"),
logger: logger,
logCh: make(chan *agentproto.BoundaryLog, 2*defaultBatchSize),
batchSize: defaultBatchSize,
batchTimerDuration: defaultBatchTimerDuration,
sessionID: uuid.MustParse("00000000-0000-4000-8000-000000000001"),
confinedProcessName: "claude",
}

return auditor, serverConn
Expand Down
50 changes: 34 additions & 16 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"fmt"
"path/filepath"
"strings"

"github.com/coder/serpent"
Expand Down Expand Up @@ -100,6 +101,22 @@ type AppConfig struct {
// all audit events produced by this boundary invocation into a
// single session. Set by Run, not by configuration.
SessionID uuid.UUID

// ConfinedProcessName is the base name of the process boundary is
// confining (e.g. "claude", "codex"), derived from TargetCMD. It is
// reported alongside audit logs so that sessions can be attributed to
// the process that generated them.
ConfinedProcessName string
}

// confinedProcessName returns a human-readable name for the process being
// confined, derived from the first element of the target command. It returns
// an empty string when no command is present.
func confinedProcessName(targetCMD []string) string {
if len(targetCMD) == 0 {
return ""
}
return filepath.Base(targetCMD[0])
}

func NewAppConfigFromCliConfig(cfg CliConfig, targetCMD []string, environ []string) (AppConfig, error) {
Expand All @@ -124,20 +141,21 @@ func NewAppConfigFromCliConfig(cfg CliConfig, targetCMD []string, environ []stri
}

return AppConfig{
AllowRules: allAllowStrings,
LogLevel: cfg.LogLevel.Value(),
LogDir: cfg.LogDir.Value(),
ProxyPort: cfg.ProxyPort.Value(),
PprofEnabled: cfg.PprofEnabled.Value(),
PprofPort: cfg.PprofPort.Value(),
JailType: jailType,
UseRealDNS: cfg.UseRealDNS.Value(),
NoUserNamespace: cfg.NoUserNamespace.Value(),
TargetCMD: targetCMD,
UserInfo: userInfo,
DisableAuditLogs: cfg.DisableAuditLogs.Value(),
LogProxySocketPath: cfg.LogProxySocketPath.Value(),
SessionCorrelation: sc,
AllowRules: allAllowStrings,
LogLevel: cfg.LogLevel.Value(),
LogDir: cfg.LogDir.Value(),
ProxyPort: cfg.ProxyPort.Value(),
PprofEnabled: cfg.PprofEnabled.Value(),
PprofPort: cfg.PprofPort.Value(),
JailType: jailType,
UseRealDNS: cfg.UseRealDNS.Value(),
NoUserNamespace: cfg.NoUserNamespace.Value(),
TargetCMD: targetCMD,
UserInfo: userInfo,
DisableAuditLogs: cfg.DisableAuditLogs.Value(),
LogProxySocketPath: cfg.LogProxySocketPath.Value(),
SessionCorrelation: sc,
ConfinedProcessName: confinedProcessName(targetCMD),
}, nil
}

Expand All @@ -152,8 +170,8 @@ func buildSessionCorrelation(cfg CliConfig, environ []string) (SessionCorrelatio
targets := append(cfg.InjectSessionIDTargets.Value(), cfg.InjectSessionIDTarget.Value()...)

if len(targets) == 0 && cfg.SessionCorrelationEnabled.Value() {
if t := DefaultInjectTargetFromEnv(environ); t != "" {
targets = []string{t}
if derived := DefaultInjectTargetsFromEnv(environ); len(derived) > 0 {
targets = derived
}
}

Expand Down
30 changes: 20 additions & 10 deletions config/session_correlation.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ const (
// exactly this header name.
SequenceNumberHeaderName = "X-Coder-Agent-Firewall-Sequence-Number"

// DefaultAIBridgePath is the path glob used when auto-deriving an inject
// target from CODER_AGENT_URL.
// DefaultAIGatewayPath is the current AI Gateway route prefix glob used
// when auto-deriving an inject target from CODER_AGENT_URL.
DefaultAIGatewayPath = "/api/v2/ai-gateway/*"

// DefaultAIBridgePath is the backward-compatible aibridge alias route
// prefix glob used when auto-deriving an inject target from
// CODER_AGENT_URL.
DefaultAIBridgePath = "/api/v2/aibridge/*"

// CoderAgentURLEnv is the environment variable set by the Coder workspace
Expand All @@ -46,15 +51,17 @@ type SessionCorrelationConfig struct {
InjectTargets []string
}

// DefaultInjectTargetFromEnv derives an inject target rule string from the
// CODER_AGENT_URL variable in the provided environment slice. It returns ""
// DefaultInjectTargetsFromEnv derives inject target rule strings from the
// CODER_AGENT_URL variable in the provided environment slice. It returns nil
// if the variable is absent, empty, or not a valid URL with a host. The
// derived target uses DefaultAIBridgePath as the path glob so that all AI
// Bridge traffic on the control-plane host is matched.
// derived targets cover both the current AI Gateway route prefix
// (DefaultAIGatewayPath) and its backward-compatible aibridge alias
// (DefaultAIBridgePath) so that clients hitting either path on the
// control-plane host get correlation headers injected.
//
// The environ parameter is accepted rather than reading os.Environ directly so
// that callers (and tests) can supply an arbitrary environment.
func DefaultInjectTargetFromEnv(environ []string) string {
func DefaultInjectTargetsFromEnv(environ []string) []string {
var raw string
for _, e := range environ {
k, v, ok := strings.Cut(e, "=")
Expand All @@ -64,15 +71,18 @@ func DefaultInjectTargetFromEnv(environ []string) string {
}
}
if raw == "" {
return ""
return nil
}

u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return ""
return nil
}

return fmt.Sprintf("domain=%s path=%s", u.Hostname(), DefaultAIBridgePath)
return []string{
fmt.Sprintf("domain=%s path=%s", u.Hostname(), DefaultAIGatewayPath),
fmt.Sprintf("domain=%s path=%s", u.Hostname(), DefaultAIBridgePath),
}
}

// ValidateSessionCorrelation checks that the session correlation config
Expand Down
Loading
Loading