diff --git a/go/internal/runner/gateway/forge.go b/go/internal/runner/gateway/forge.go new file mode 100644 index 00000000..6ad5e4fc --- /dev/null +++ b/go/internal/runner/gateway/forge.go @@ -0,0 +1,83 @@ +//go:build unix + +package gateway + +// forge.go is the Runner->Server forward for agent-initiated forge calls +// (create/comment/get/list an issue or PR, submit a review): the +// AgentGateway.Forge handler an in-container agent reaches over its per-container +// socket. It is the sibling of Comms (gateway.go) and Lifecycle (lifecycle.go) — +// same seam, same posture: map the socket -> the container it belongs to -> the +// one session bound to that container, then forward the call to the Server as +// RelayForgeCall(session_id, call). The Runner resolves NO account and sets NO +// actor: the Server resolves session_id -> account from its own Provision-time +// binding and stamps the owner header itself, fail-closed (Compass forge write +// path T5). +// +// A call arriving before the container's session is bound (socket live at +// Provision, before Start mints the session) fails closed CodePermissionDenied — +// never a forward with an empty session id, never a bootstrap-admin-attributed +// forge write. + +import ( + "context" + "errors" + + "connectrpc.com/connect" + + compassv1internal "github.com/sealedsecurity/compass/go/internal/gen/compass/v1" +) + +// errNoSessionForForge is the fail-closed cause when a forge call arrives on a +// container's socket before Start has bound a session to it. It maps to +// CodePermissionDenied — the call is refused, never forwarded with an empty +// session id and never attributed to any account. The forge twin of +// errNoSessionForLifecycle. +var errNoSessionForForge = errors.New("gateway: no live session bound to container") + +// errNilForgeResult is the cause when the Server's RelayForgeCall returns a +// response with no result message — a malformed reply the gateway surfaces as +// CodeInternal rather than a success wrapping a nil result. The forge twin of +// errNilLifecycleResult. +var errNilForgeResult = errors.New("gateway: relay returned no forge result") + +// Forge forwards one agent-initiated forge call to the Server's RelayForgeCall +// under the session bound to this container. It fails closed +// (CodePermissionDenied) when no live session maps to the container — the socket +// is live from Provision, before Start binds the session, so a call in that +// window must never forward with an empty session id nor attribute to any +// account. The inbound deadline rides ctx into the forward. +// +// A Server-side in-band tool failure (unknown coordinate, rate limit, bad input) +// rides back as the ForgeCallError variant of the result, NOT a Connect error, +// so a single failed call never tears the transport down. A genuine transport +// failure (Server unreachable) surfaces as a Connect error, which the agent +// renders as an in-band tool error too. Mirrors Lifecycle exactly. +func (g *Gateway) Forge( + ctx context.Context, req *connect.Request[compassv1internal.ForgeCallRequest], +) (*connect.Response[compassv1internal.ForgeCallResult], error) { + sessionID, ok := g.sessions.Session(g.containerName) + // An empty session id is unbound too: the resolver must never hand back a + // live binding to the empty session, but treat "" as unbound rather than + // forward it — the handler promises never to relay an empty session id. + if !ok || sessionID == "" { + return nil, connect.NewError(connect.CodePermissionDenied, errNoSessionForForge) + } + + resp, err := g.forge.RelayForgeCall(ctx, connect.NewRequest(&compassv1internal.RelayForgeCallRequest{ + SessionId: sessionID, + Call: req.Msg, + })) + if err != nil { + // A transport failure on the Runner->Server leg. Surfaced as a Connect + // error the agent renders in-band; the turn is not torn down. + return nil, err + } + // A well-formed RelayForgeCallResponse always carries a result; a nil result + // is a malformed Server response, surfaced as a Connect error (never a + // success wrapping a nil Msg the agent would deref). + result := resp.Msg.GetResult() + if result == nil { + return nil, connect.NewError(connect.CodeInternal, errNilForgeResult) + } + return connect.NewResponse(result), nil +} diff --git a/go/internal/runner/gateway/forge_test.go b/go/internal/runner/gateway/forge_test.go new file mode 100644 index 00000000..22388040 --- /dev/null +++ b/go/internal/runner/gateway/forge_test.go @@ -0,0 +1,193 @@ +//go:build unix + +package gateway + +// Hermetic suite for the Runner->Server forward handler Gateway.Forge (create/ +// comment/get/list an issue or PR, submit a review — Compass forge write path +// T5). The forge twin of the Lifecycle suite: white-box (package gateway), +// sleep-free — the fakes record every fact synchronously, so every assertion +// reads a value the in-memory call already produced. +// +// The load-bearing case is the fail-closed guard: a forge call arriving before +// Start binds a session (the socket is live from Provision) MUST be refused +// CodePermissionDenied and MUST NEVER forward with an empty session id — a forge +// write under an unbound session would be attributed by the Server to no +// account, the exact security hole the seam exists to close. + +import ( + "context" + "errors" + "testing" + + "connectrpc.com/connect" + + compassv1internal "github.com/sealedsecurity/compass/go/internal/gen/compass/v1" +) + +// recordedForgeCall is one observed forward: the ctx the handler propagated and +// the request payload it built. +type recordedForgeCall struct { + ctx context.Context + req *compassv1internal.RelayForgeCallRequest +} + +// fakeForgeRelay is a hand-written ForgeRelay that records every RelayForgeCall +// it receives and returns a canned response or a canned transport error. A test +// asserts the forward through the recorded calls; NEVER forwarding is proven by +// an empty calls slice. The forge twin of fakeLifecycleRelay. +type fakeForgeRelay struct { + resp *compassv1internal.RelayForgeCallResponse + err error + + calls []recordedForgeCall +} + +func (f *fakeForgeRelay) RelayForgeCall( + ctx context.Context, req *connect.Request[compassv1internal.RelayForgeCallRequest], +) (*connect.Response[compassv1internal.RelayForgeCallResponse], error) { + f.calls = append(f.calls, recordedForgeCall{ctx: ctx, req: req.Msg}) + if f.err != nil { + return nil, f.err + } + return connect.NewResponse(f.resp), nil +} + +// forgeCreateIssueCall builds a ForgeCallRequest carrying a create_issue variant +// under testCallID — the verbatim payload an in-container agent sends. +func forgeCreateIssueCall() *compassv1internal.ForgeCallRequest { + return &compassv1internal.ForgeCallRequest{ + CallId: testCallID, + Call: &compassv1internal.ForgeCallRequest_CreateIssue{CreateIssue: &compassv1internal.CreateIssueRequest{ + Repo: "o/r", + Title: "t", + }}, + } +} + +// Case 1. SECURITY CORE — no session bound to the container fails closed +// CodePermissionDenied and the relay is NEVER invoked. The socket is live from +// Provision, before Start binds the session; a forge call in that window must +// never forward with an empty session id nor attribute to any account. Mutation +// proof: flip `if !ok || sessionID == "" {` to `if false {` -> the handler +// forwards an empty session id -> the len(relay.calls) != 0 / nil-error +// assertions go RED. +func TestForgeNoSessionFailsClosedPermissionDenied(t *testing.T) { + sessions := &fakeSessions{ok: false} + relay := &fakeForgeRelay{} + g := NewGateway(context.Background(), "cnt-A", Deps{Sessions: sessions, Forge: relay}) + + resp, err := g.Forge(context.Background(), connect.NewRequest(forgeCreateIssueCall())) + if err == nil { + t.Fatal("Forge with no session bound = nil error, want CodePermissionDenied (fail closed)") + } + if got := connect.CodeOf(err); got != connect.CodePermissionDenied { + t.Fatalf("no-session error code = %v, want PermissionDenied", got) + } + if resp != nil { + t.Fatalf("no-session response = %+v, want nil", resp) + } + if len(relay.calls) != 0 { + t.Fatalf("relay forwarded %d calls with no session bound, want 0 (never forward an empty session id)", len(relay.calls)) + } +} + +// Case 1b. SECURITY CORE — a bound session whose id is empty (ok:true, +// sessionID:"") is still unbound for attribution: it fails closed +// CodePermissionDenied and the relay is NEVER invoked. This pins the SECOND +// clause of the `!ok || sessionID == ""` guard, which Case 1 (ok:false) leaves +// unexercised. Mutation proof: drop `|| sessionID == ""` -> an empty session id +// forwards -> the len(relay.calls) != 0 / nil-error assertions go RED. +func TestForgeEmptySessionIDFailsClosedPermissionDenied(t *testing.T) { + sessions := &fakeSessions{sessionID: "", ok: true} + relay := &fakeForgeRelay{} + g := NewGateway(context.Background(), "cnt-A", Deps{Sessions: sessions, Forge: relay}) + + resp, err := g.Forge(context.Background(), connect.NewRequest(forgeCreateIssueCall())) + if err == nil { + t.Fatal("Forge with an empty session id = nil error, want CodePermissionDenied (empty session is unbound)") + } + if got := connect.CodeOf(err); got != connect.CodePermissionDenied { + t.Fatalf("empty-session error code = %v, want PermissionDenied", got) + } + if resp != nil { + t.Fatalf("Forge returned a non-nil response alongside the fail-closed error") + } + if len(relay.calls) != 0 { + t.Fatalf("relay forwarded %d calls for an empty session id, want 0 (never forward an empty session id)", len(relay.calls)) + } +} + +// Case 2. HAPPY PATH — a forge call arriving while a session is bound forwards to +// the Server carrying the EXACT session id the Runner structurally owns (never +// one read off the request) and the agent's call verbatim, and the Server's +// result flows back. Forwarding the exact bound session id IS the attribution +// proof at the Runner seam: the Runner asserts no account. +func TestForgeHappyPathForwardsUnderBoundSessionAndReturnsResult(t *testing.T) { + sessions := &fakeSessions{sessionID: "sess-7", ok: true} + wantResult := &compassv1internal.ForgeCallResult{ + CallId: testCallID, + Result: &compassv1internal.ForgeCallResult_IssueComment{IssueComment: &compassv1internal.CommentRef{Url: "https://forge/1"}}, + } + relay := &fakeForgeRelay{resp: &compassv1internal.RelayForgeCallResponse{Result: wantResult}} + g := NewGateway(context.Background(), "cnt-A", Deps{Sessions: sessions, Forge: relay}) + + resp, err := g.Forge(context.Background(), connect.NewRequest(forgeCreateIssueCall())) + if err != nil { + t.Fatalf("Forge = %v, want success", err) + } + if len(relay.calls) != 1 { + t.Fatalf("relay forwarded %d calls, want exactly 1", len(relay.calls)) + } + got := relay.calls[0].req + if got.GetSessionId() != "sess-7" { + t.Fatalf("forwarded session id = %q, want sess-7 (the bound session the Runner owns)", got.GetSessionId()) + } + if got.GetCall().GetCallId() != testCallID { + t.Fatalf("forwarded call id = %q, want %q (the agent's call, verbatim)", got.GetCall().GetCallId(), testCallID) + } + if got.GetCall().GetCreateIssue().GetRepo() != "o/r" { + t.Fatalf("forwarded repo = %q, want o/r (verbatim payload)", got.GetCall().GetCreateIssue().GetRepo()) + } + if resp.Msg.GetIssueComment().GetUrl() != "https://forge/1" { + t.Fatalf("returned result = %+v, want the Server's forge result", resp.Msg) + } +} + +// A transport failure on the Runner->Server leg surfaces as a Connect error (the +// agent renders it in-band), never a success wrapping a nil result. +func TestForgeTransportFailurePropagatesConnectError(t *testing.T) { + sessions := &fakeSessions{sessionID: "sess-7", ok: true} + relay := &fakeForgeRelay{err: connect.NewError(connect.CodeUnavailable, errors.New("server unreachable"))} + g := NewGateway(context.Background(), "cnt-A", Deps{Sessions: sessions, Forge: relay}) + + resp, err := g.Forge(context.Background(), connect.NewRequest(forgeCreateIssueCall())) + if err == nil { + t.Fatal("Forge with a failing relay = nil error, want the transport error propagated") + } + if got := connect.CodeOf(err); got != connect.CodeUnavailable { + t.Fatalf("propagated error code = %v, want Unavailable", got) + } + if resp != nil { + t.Fatalf("transport-failure response = %+v, want nil", resp) + } +} + +// A well-formed RelayForgeCallResponse always carries a result; a nil result is +// a malformed Server reply surfaced as CodeInternal, never a success wrapping a +// nil Msg the agent would deref. +func TestForgeNilResultIsInternalError(t *testing.T) { + sessions := &fakeSessions{sessionID: "sess-7", ok: true} + relay := &fakeForgeRelay{resp: &compassv1internal.RelayForgeCallResponse{}} + g := NewGateway(context.Background(), "cnt-A", Deps{Sessions: sessions, Forge: relay}) + + resp, err := g.Forge(context.Background(), connect.NewRequest(forgeCreateIssueCall())) + if err == nil { + t.Fatal("Forge with a nil relay result = nil error, want CodeInternal") + } + if got := connect.CodeOf(err); got != connect.CodeInternal { + t.Fatalf("nil-result error code = %v, want Internal", got) + } + if resp != nil { + t.Fatalf("nil-result response = %+v, want nil", resp) + } +} diff --git a/go/internal/runner/gateway/gateway.go b/go/internal/runner/gateway/gateway.go index 51baf3ab..7b61182c 100644 --- a/go/internal/runner/gateway/gateway.go +++ b/go/internal/runner/gateway/gateway.go @@ -108,6 +108,17 @@ type LifecycleRelay interface { RelayLifecycleCall(ctx context.Context, req *connect.Request[compassv1internal.RelayLifecycleCallRequest]) (*connect.Response[compassv1internal.RelayLifecycleCallResponse], error) } +// ForgeRelay forwards one agent-initiated forge call (create/comment/get/list an +// issue or PR, submit a review) to the Server under the resolved session. +// Sibling of LifecycleRelay: the same pure-forwarder shape over the generated +// RunnerServiceClient's RelayForgeCall, narrowed to the one method the gateway +// needs. The real client satisfies it; a test supplies a fake. The Runner sends +// the session_id it structurally owns and the agent's call verbatim, and asserts +// no account. +type ForgeRelay interface { + RelayForgeCall(ctx context.Context, req *connect.Request[compassv1internal.RelayForgeCallRequest]) (*connect.Response[compassv1internal.RelayForgeCallResponse], error) +} + // ConversationCommitter is the narrow slice of the generated RunnerServiceClient // the durable conversation path needs — just CommitConversationFrame. The real // client satisfies it; a test supplies a fake. Mirrors CommsRelay's narrowing of @@ -137,6 +148,11 @@ type Gateway struct { // forward. Same pure-forwarder posture: no account, session id the Runner // structurally owns. lifecycle LifecycleRelay + // forge forwards ONE agent-initiated forge call (create/comment/get/list an + // issue or PR, submit a review) to the Server (RelayForgeCall), the sibling + // of lifecycle's spawn/despawn forward. Same pure-forwarder posture: no + // account, session id the Runner structurally owns. + forge ForgeRelay // committer forwards ONE durable conversation frame to the Server for commit // (CommitConversationFrame, the delivered-or-erred unary) and returns the // commit outcome. Durable conversation frames leave the loss-tolerant Publish @@ -221,6 +237,8 @@ type Deps struct { Relay CommsRelay // Lifecycle forwards an agent-initiated spawn/despawn call to the Server (RelayLifecycleCall). Lifecycle LifecycleRelay + // Forge forwards an agent-initiated forge call to the Server (RelayForgeCall). + Forge ForgeRelay // Events forwards trace/session telemetry up the loss-tolerant PublishEvents stream. Events EventRelay // Committer forwards a durable conversation frame to the Server for commit (CommitConversationFrame). @@ -249,6 +267,7 @@ func NewGateway(baseCtx context.Context, containerName string, deps Deps) *Gatew sessions: deps.Sessions, relay: deps.Relay, lifecycle: deps.Lifecycle, + forge: deps.Forge, events: deps.Events, committer: deps.Committer, control: noopControlRouter{}, diff --git a/go/internal/runner/host.go b/go/internal/runner/host.go index c6ff120b..50b5f35e 100644 --- a/go/internal/runner/host.go +++ b/go/internal/runner/host.go @@ -910,7 +910,7 @@ func (h *agentHost) serveSocket(ctx context.Context, containerName string) (*gat } h.mu.Unlock() path := filepath.Join(h.runtimeDir, agentSocketDir, containerName, agentSocketFile) - listener, err := gateway.Serve(ctx, path, containerName, gateway.Deps{Sessions: h, Relay: h.link.client, Lifecycle: h.link.client, Events: h.link.client, Committer: h.link.client}) + listener, err := gateway.Serve(ctx, path, containerName, gateway.Deps{Sessions: h, Relay: h.link.client, Lifecycle: h.link.client, Events: h.link.client, Committer: h.link.client, Forge: h.link.client}) if err != nil { return nil, fmt.Errorf("serving agent socket for container %q: %w", containerName, err) } diff --git a/go/internal/runnerhub/handler.go b/go/internal/runnerhub/handler.go index 4f48a328..d6c54619 100644 --- a/go/internal/runnerhub/handler.go +++ b/go/internal/runnerhub/handler.go @@ -211,6 +211,25 @@ func (h *Handler) RelayBoardCall(ctx context.Context, req *connect.Request[compa return connect.NewResponse(resp), nil } +// RelayForgeCall forwards one agent-initiated forge call (create/comment/get/ +// list an issue or PR, submit a review) into the hub, which resolves the relayed +// session_id to its bound agent account (the caller) and delegates under that +// account, fail-closed (relay_forge.go). The bearer interceptor has already +// Kind-gated the caller to a SubjectRunner subject; the defense-in-depth check +// rejects a context with none. An unresolved session is a Connect CodeNotFound; +// a tool failure is the in-band ForgeCallError variant (never a stream +// teardown). +func (h *Handler) RelayForgeCall(ctx context.Context, req *connect.Request[compassv1internal.RelayForgeCallRequest]) (*connect.Response[compassv1internal.RelayForgeCallResponse], error) { + if _, ok := runnerSubjectFrom(ctx); !ok { + return nil, errUnauthenticated + } + resp, err := h.hub.RelayForgeCall(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil +} + // CommitConversationFrame durably commits one agent-authored conversation frame // and returns the commit outcome — the DURABLE counterpart to PublishEvents. The // hub resolves the relayed session_id to its bound agent account and commits the diff --git a/go/internal/runnerhub/hub.go b/go/internal/runnerhub/hub.go index 18ac7914..b12ddab1 100644 --- a/go/internal/runnerhub/hub.go +++ b/go/internal/runnerhub/hub.go @@ -279,6 +279,13 @@ type Hub struct { // path never race. Nil-safe: a hub with none wired fails RelayBoardCall // closed CodeUnavailable — the board write leg is not mounted. boardCaller BoardCaller + // forgeCaller is the forge-write execution seam RelayForgeCall delegates a + // resolved forge call to (Compass forge write path T4). Nil until + // SetForgeCaller wires it (after both hub and forgeService exist, breaking + // their construction cycle), and read under mu so the setter and the serve + // path never race. Nil-safe: a hub with none wired fails RelayForgeCall + // closed CodeUnavailable — the forge write leg is not mounted. + forgeCaller ForgeCaller // runnerReadyHook, when set, is invoked once each time a Runner's Sessions // command stream attaches (fired from the Sessions handler after // router.attach binds the live send, on its own goroutine). It is the seam diff --git a/go/internal/runnerhub/relay_forge.go b/go/internal/runnerhub/relay_forge.go new file mode 100644 index 00000000..db339622 --- /dev/null +++ b/go/internal/runnerhub/relay_forge.go @@ -0,0 +1,145 @@ +//go:build unix + +// The agent-forge Server leg: the RelayForgeCall resolution edge the Runner +// forwards each agent-initiated forge call into (Compass forge write path T5). +// It is the forge sibling of the RelayBoardCall board leg (relay_board.go), the +// RelayLifecycleCall lifecycle leg (relay_lifecycle.go), and the RelayCommsCall +// comms leg (relay_comms.go), and shares their trust model exactly. +// +// Trust model (the load-bearing security leg). The Runner is a pure forwarder: +// it sends RelayForgeCall{session_id, call} and asserts NO account. The SERVER +// resolves session_id -> caller agent account from THIS hub's own binding (the +// same binding RelayBoardCall/RelayLifecycleCall/RelayCommsCall resolve against) +// and delegates the call to the ForgeCaller under that resolved caller account, +// passing the session id through (the ForgeCaller stamps the owner header from +// it). An unknown, stopped, or reconnect-dropped session fails closed +// CodeNotFound: never a stale account, never the bootstrap admin. A session_id +// on the wire selects an account, it never carries one. +package runnerhub + +import ( + "context" + "errors" + + "connectrpc.com/connect" + + compassv1internal "github.com/sealedsecurity/compass/go/internal/gen/compass/v1" + "github.com/sealedsecurity/compass/go/internal/store" +) + +// ForgeCaller executes an agent-initiated forge call (create/comment/get/list an +// issue or PR, submit a review) as a resolved caller agent account. The forge +// service (T4) implements it over the write chokepoint that stamps the owner +// header, dispatches the call oneof, and enforces the body limit — the hub +// depends only on this narrow surface so it does not pull the whole forge +// service in (pattern: BoardCaller / LifecycleCaller / CommsCaller). It is the +// safe Runner->Server leg: the caller account is resolved Server-side from the +// hub's own binding, never asserted by the Runner (Compass forge write path T5). +// The signature carries the resolved caller AccountID for attribution AND the +// session id through, because the chokepoint interpolates the session id into +// the owner header it stamps. MVP scope ships no scope rejection +// (single-trust-domain, Resolved decision 2). A tool-level failure is returned +// IN-BAND inside the ForgeCallResult error arm, never as a Go error torn down +// onto the transport. +type ForgeCaller interface { + ExecuteForgeCallAsAccount(ctx context.Context, caller store.AccountID, sessionID string, call *compassv1internal.ForgeCallRequest) (*compassv1internal.ForgeCallResult, error) +} + +// errForgeUnavailable is the fail-closed cause when a hub with no ForgeCaller +// wired receives a RelayForgeCall. It maps to CodeUnavailable — the forge write +// leg is not mounted, never a silent success. +var errForgeUnavailable = errors.New("runnerhub: no forge caller wired to serve RelayForgeCall") + +// errForgeNoResult is the cause when a wired ForgeCaller returns a nil result on +// the nil-error arm — a malformed reply, surfaced in-band as CodeInternal rather +// than nil-dereferenced on the resolution edge. +var errForgeNoResult = errors.New("runnerhub: forge caller returned no result") + +// SetForgeCaller wires the forge execution seam after construction, so no NewHub +// caller signature changes and the hub<->forgeService construction cycle (the +// service needs the store + provider registry; the hub needs the service to +// serve RelayForgeCall) is broken exactly as SetBoardCaller breaks the board +// cycle. A hub with none wired fails RelayForgeCall closed CodeUnavailable. +// Wired under mu; read under mu. +func (h *Hub) SetForgeCaller(c ForgeCaller) { + h.mu.Lock() + defer h.mu.Unlock() + h.forgeCaller = c +} + +// RelayForgeCall resolves the relayed session_id to its bound agent account (the +// CALLER) and delegates the forge call to the ForgeCaller under that caller +// account, passing the session id through. Guard order, each fail-closed: (1) no +// ForgeCaller wired -> CodeUnavailable, checked BEFORE session resolution; (2) +// session_id resolves to no live binding -> CodeNotFound (never a stale account, +// never the bootstrap admin); (3) delegate under the RESOLVED caller AccountID +// (never request-asserted, never admin), passing the session id through. A +// tool-level failure (unknown coordinate, rate limit, bad input) is returned +// IN-BAND as the ForgeCallError variant of the result — the agent renders it and +// the transport survives — exactly the RelayBoardCall split: only a resolution +// miss / no-caller is a Connect error. +func (h *Hub) RelayForgeCall( + ctx context.Context, + req *compassv1internal.RelayForgeCallRequest, +) (*compassv1internal.RelayForgeCallResponse, error) { + h.mu.Lock() + caller := h.forgeCaller + h.mu.Unlock() + if caller == nil { + return nil, connect.NewError(connect.CodeUnavailable, errForgeUnavailable) + } + sessionID := req.GetSessionId() + account, ok := h.accountForSession(sessionID) + if !ok { + // Fail closed: no live session maps to this id. Never a stale account, + // never the bootstrap admin — a hard CodeNotFound the Runner surfaces. + return nil, connect.NewError( + connect.CodeNotFound, + errors.New("runnerhub: no agent account bound to session"), + ) + } + + call := req.GetCall() + callID := call.GetCallId() + result, err := caller.ExecuteForgeCallAsAccount(ctx, account, sessionID, call) + if err != nil { + // A tool-level failure (or a malformed call) is in-band: the agent gets a + // ForgeCallError it renders to the model, and the transport survives. Only + // a resolution miss / no-caller (above) is a Connect error. + return &compassv1internal.RelayForgeCallResponse{ + Result: &compassv1internal.ForgeCallResult{ + CallId: callID, + Result: &compassv1internal.ForgeCallResult_Error{Error: forgeCallError(err)}, + }, + }, nil + } + if result == nil { + // Defensive: a ForgeCaller must return a non-nil result on the nil-error + // arm (the sibling legs get this for free from their internal executor, + // which always builds a fresh result; the forge leg calls the external + // ForgeCaller directly). A (nil, nil) return is a malformed reply, not a + // tool failure — surface it in-band as CodeInternal rather than nil-deref + // on this security-critical resolution edge. + return &compassv1internal.RelayForgeCallResponse{ + Result: &compassv1internal.ForgeCallResult{ + CallId: callID, + Result: &compassv1internal.ForgeCallResult_Error{ + Error: forgeCallError(connect.NewError(connect.CodeInternal, errForgeNoResult)), + }, + }, + }, nil + } + result.CallId = callID + return &compassv1internal.RelayForgeCallResponse{Result: result}, nil +} + +// forgeCallError maps a forge execution error onto the in-band ForgeCallError +// the agent renders. The code is the Connect status token (e.g. "not_found" for +// an unknown coordinate, "resource_exhausted" for a rate limit); the message is +// the error text. A non-Connect error is CodeUnknown's token. +func forgeCallError(err error) *compassv1internal.ForgeCallError { + return &compassv1internal.ForgeCallError{ + Code: connect.CodeOf(err).String(), + Message: err.Error(), + } +} diff --git a/go/internal/runnerhub/relay_forge_test.go b/go/internal/runnerhub/relay_forge_test.go new file mode 100644 index 00000000..448fad57 --- /dev/null +++ b/go/internal/runnerhub/relay_forge_test.go @@ -0,0 +1,265 @@ +//go:build unix + +package runnerhub + +// The agent-forge Server leg (Compass forge write path T5, fail-closed authz — +// the load-bearing security leg, exactly as RelayBoardCall / RelayLifecycleCall / +// RelayCommsCall). Every test here defends one invariant of the +// session->account resolution + RelayForgeCall handler: the Runner asserts no +// account, so the SERVER's binding is the sole authority for whose account a +// relayed forge call runs under. A regression that let an unbound, stopped, or +// reconnect-dropped session resolve to ANY account — or delegated under the +// wrong account, or turned a tool failure into a transport teardown — must +// redden a test below. +// +// White-box (package runnerhub) so the tests drive the unexported binding +// lifecycle and the resolution edge directly, asserting the account attribution +// through the fake ForgeCaller. Sleep-free: the hub calls the caller inline, so +// every assertion reads a synchronously-recorded fact. + +import ( + "context" + "errors" + "sync" + "testing" + + "connectrpc.com/connect" + + compassv1internal "github.com/sealedsecurity/compass/go/internal/gen/compass/v1" + "github.com/sealedsecurity/compass/go/internal/store" +) + +// forgeCall records one ForgeCaller invocation: the account the hub attributed +// it to, the session id it passed through, and the request forwarded. A test +// asserts the hub delegated under the RESOLVED caller account (never the +// Runner's, never admin) and threaded the session id. +type forgeCall struct { + account store.AccountID + sessionID string + call *compassv1internal.ForgeCallRequest +} + +// fakeForgeCaller is a hand-written ForgeCaller mirroring fakeBoardCaller: it +// records every call (account + session id + request) so a test asserts the hub +// attributed to the bound account and forwarded the exact request, and returns a +// configurable canned result or error so a test drives both the success and the +// in-band tool-error path without a real service. Concurrency-safe for parity +// with the real caller, though the hub calls it inline. +type fakeForgeCaller struct { + mu sync.Mutex + calls []forgeCall + + result *compassv1internal.ForgeCallResult + err error +} + +func (f *fakeForgeCaller) ExecuteForgeCallAsAccount(_ context.Context, caller store.AccountID, sessionID string, call *compassv1internal.ForgeCallRequest) (*compassv1internal.ForgeCallResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, forgeCall{account: caller, sessionID: sessionID, call: call}) + if f.err != nil { + return nil, f.err + } + return f.result, nil +} + +func (f *fakeForgeCaller) snapshot() []forgeCall { + f.mu.Lock() + defer f.mu.Unlock() + return append([]forgeCall(nil), f.calls...) +} + +// newHubWithForge builds a hub whose ForgeCaller is the returned fake (wired +// post-construction via SetForgeCaller, the real wiring path), so a +// RelayForgeCall test drives the resolve->attribute->delegate path and asserts +// on the caller account the fake was called with. Like newHubOnly otherwise. +func newHubWithForge() (*Hub, *fakeForgeCaller) { + fake := &fakeForgeCaller{} + hub := newHubOnly() + hub.SetForgeCaller(fake) + return hub, fake +} + +// relayCreateIssue builds a RelayForgeCallRequest carrying a create_issue +// variant under callID. +func relayCreateIssue(sessionID, callID string, req *compassv1internal.CreateIssueRequest) *compassv1internal.RelayForgeCallRequest { + return &compassv1internal.RelayForgeCallRequest{ + SessionId: sessionID, + Call: &compassv1internal.ForgeCallRequest{ + CallId: callID, + Call: &compassv1internal.ForgeCallRequest_CreateIssue{CreateIssue: req}, + }, + } +} + +// 1. An unbound session fails closed CodeNotFound and NEVER reaches the caller — +// no delegation is attempted for a session the hub has no binding for. This is +// the core fail-closed guard: a session_id on the wire selects an account, it +// never carries one, so an id the hub never bound resolves to nothing. +// +// Mutation: hardcode accountForSession to return a fixed account (ok=true) and +// this test fails twice over — the error becomes nil and the caller records a +// call. +func TestRelayForgeCallUnboundSessionFailsClosedNotFound(t *testing.T) { + hub, fake := newHubWithForge() + + _, err := hub.RelayForgeCall(context.Background(), relayCreateIssue("never-bound", "fc-1", &compassv1internal.CreateIssueRequest{Repo: "o/r", Title: "t"})) + if err == nil { + t.Fatal("RelayForgeCall for an unbound session = nil error, want CodeNotFound (fail closed)") + } + if got := connect.CodeOf(err); got != connect.CodeNotFound { + t.Fatalf("unbound-session error code = %v, want NotFound", got) + } + if calls := fake.snapshot(); len(calls) != 0 { + t.Fatalf("caller was invoked %d times for an unbound session, want 0 (no delegation attempt)", len(calls)) + } +} + +// 2. A hub with no ForgeCaller wired fails RelayForgeCall closed with +// CodeUnavailable — the forge write leg is not mounted, never a silent success. +// This is checked BEFORE session resolution, so even a bound session gets +// Unavailable on a caller-less hub. +// +// Mutation: reordering the checks so resolution runs first would change the code +// to NotFound for an unbound+nil case — the second sub-assertion (unbound session +// + nil caller still Unavailable) reddens that reorder. +func TestRelayForgeCallNilCallerIsUnavailableBeforeResolution(t *testing.T) { + t.Run("bound session still Unavailable", func(t *testing.T) { + hub := newHubOnly() // no ForgeCaller wired + bindLiveSession(hub) // a live binding exists, proving the nil guard precedes resolution + + _, err := hub.RelayForgeCall(context.Background(), relayCreateIssue("sess-1", "fc-2", &compassv1internal.CreateIssueRequest{Repo: "o/r", Title: "t"})) + if err == nil { + t.Fatal("RelayForgeCall on a caller-less hub = nil error, want CodeUnavailable") + } + if got := connect.CodeOf(err); got != connect.CodeUnavailable { + t.Fatalf("nil-caller (bound session) error code = %v, want Unavailable", got) + } + }) + t.Run("unbound session still Unavailable (nil-check precedes resolution)", func(t *testing.T) { + hub := newHubOnly() // no ForgeCaller wired, no binding + + _, err := hub.RelayForgeCall(context.Background(), relayCreateIssue("never-bound", "fc-2b", &compassv1internal.CreateIssueRequest{Repo: "o/r", Title: "t"})) + if err == nil { + t.Fatal("RelayForgeCall on a caller-less hub (unbound) = nil error, want CodeUnavailable") + } + if got := connect.CodeOf(err); got != connect.CodeUnavailable { + t.Fatalf("nil-caller (unbound session) error code = %v, want Unavailable, not NotFound (proves nil-check precedes resolution)", got) + } + }) +} + +// 3. THE core authz test: the RESOLVED caller account (the hub's own binding) +// reaches the caller — never a request field, never a literal admin id — AND the +// session id is threaded through so the chokepoint can stamp the owner header. A +// call for the bound session_id delegates under acct-agent, the account the hub +// bound, with the bound session id. +// +// Mutation: passing a request field or a literal admin id instead of the +// resolved account reddens the account assertion; dropping the session-id +// passthrough reddens the sessionID assertion. +func TestRelayForgeCallDelegatesUnderResolvedCallerAccount(t *testing.T) { + hub, fake := newHubWithForge() + fake.result = &compassv1internal.ForgeCallResult{ + Result: &compassv1internal.ForgeCallResult_IssueComment{IssueComment: &compassv1internal.CommentRef{}}, + } + bindLiveSession(hub) // sess-1 -> acct-agent + + _, err := hub.RelayForgeCall(context.Background(), relayCreateIssue("sess-1", "fc-3", &compassv1internal.CreateIssueRequest{Repo: "o/r", Title: "t"})) + if err != nil { + t.Fatalf("RelayForgeCall(create_issue) = %v, want success", err) + } + calls := fake.snapshot() + if len(calls) != 1 { + t.Fatalf("caller invoked %d times, want exactly 1", len(calls)) + } + if calls[0].account != "acct-agent" { + t.Fatalf("caller attributed to %q, want the bound caller account acct-agent (never request-asserted, never admin)", calls[0].account) + } + if calls[0].sessionID != "sess-1" { + t.Fatalf("caller received session id %q, want the bound sess-1 (threaded for the owner-header stamp)", calls[0].sessionID) + } + if calls[0].call.GetCreateIssue().GetRepo() != "o/r" { + t.Fatalf("caller received repo %q, want the request's o/r", calls[0].call.GetCreateIssue().GetRepo()) + } +} + +// 4. A caller (tool-level) error surfaces IN-BAND in a SUCCESSFUL (nil-err) +// response as the ForgeCallError variant — the agent renders it and the +// transport survives. Only a resolution miss / no-caller is a Connect error. +// +// Mutation: returning the caller error as a Connect error (instead of in-band) +// reddens the err==nil assertion. +func TestRelayForgeCallToolErrorIsInBandNotStreamError(t *testing.T) { + hub, fake := newHubWithForge() + fake.err = connect.NewError(connect.CodeNotFound, errors.New("repo \"o/x\" does not exist")) + bindLiveSession(hub) + + resp, err := hub.RelayForgeCall(context.Background(), relayCreateIssue("sess-1", "fc-4", &compassv1internal.CreateIssueRequest{Repo: "o/x", Title: "t"})) + if err != nil { + t.Fatalf("RelayForgeCall with a tool error returned a Go error %v, want nil (in-band render)", err) + } + toolErr := resp.GetResult().GetError() + if toolErr == nil { + t.Fatal("response has no in-band ForgeCallError, want the tool failure rendered in-band") + } + if toolErr.GetCode() != "not_found" { + t.Fatalf("in-band error code = %q, want not_found", toolErr.GetCode()) + } + if toolErr.GetMessage() != "not_found: repo \"o/x\" does not exist" { + t.Fatalf("in-band error message = %q, want the caller's rendered error", toolErr.GetMessage()) + } + // The call_id still round-trips on the error variant so the agent correlates + // the failed call. + if got := resp.GetResult().GetCallId(); got != "fc-4" { + t.Fatalf("in-band error call_id = %q, want fc-4", got) + } +} + +// 5. On a successful call the minted call_id is echoed onto the result so the +// agent correlates its call, and the caller's result rides through. +func TestRelayForgeCallEchoesCallIDOnSuccess(t *testing.T) { + hub, fake := newHubWithForge() + fake.result = &compassv1internal.ForgeCallResult{ + Result: &compassv1internal.ForgeCallResult_IssueComment{IssueComment: &compassv1internal.CommentRef{}}, + } + bindLiveSession(hub) + + resp, err := hub.RelayForgeCall(context.Background(), relayCreateIssue("sess-1", "fc-5", &compassv1internal.CreateIssueRequest{Repo: "o/r", Title: "t"})) + if err != nil { + t.Fatalf("RelayForgeCall(create_issue) = %v, want success", err) + } + if got := resp.GetResult().GetCallId(); got != "fc-5" { + t.Fatalf("response call_id = %q, want the request's fc-5", got) + } + if resp.GetResult() != fake.result { + t.Fatal("response result is not the caller's result") + } +} + +// 6. A ForgeCaller that returns (nil, nil) — a nil result on the nil-error arm — +// must NOT nil-deref on the resolution edge: the malformed reply is surfaced +// in-band as CodeInternal with the call_id echoed, not a panic. The sibling +// legs get this immunity for free from their internal executor; the forge leg +// calls the external ForgeCaller directly, so the guard is explicit. +func TestRelayForgeCallNilResultIsInternalErrorInBand(t *testing.T) { + hub, fake := newHubWithForge() + fake.result = nil // (nil result, nil error) — a malformed caller reply + fake.err = nil + bindLiveSession(hub) + + resp, err := hub.RelayForgeCall(context.Background(), relayCreateIssue("sess-1", "fc-6", &compassv1internal.CreateIssueRequest{Repo: "o/r", Title: "t"})) + if err != nil { + t.Fatalf("RelayForgeCall with a nil-result caller returned a Go error %v, want nil (in-band render)", err) + } + toolErr := resp.GetResult().GetError() + if toolErr == nil { + t.Fatal("response has no in-band ForgeCallError, want the malformed nil result rendered in-band") + } + if toolErr.GetCode() != "internal" { + t.Fatalf("in-band error code = %q, want internal", toolErr.GetCode()) + } + if got := resp.GetResult().GetCallId(); got != "fc-6" { + t.Fatalf("in-band error call_id = %q, want fc-6", got) + } +}