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
83 changes: 83 additions & 0 deletions go/internal/runner/gateway/forge.go
Original file line number Diff line number Diff line change
@@ -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
}
193 changes: 193 additions & 0 deletions go/internal/runner/gateway/forge_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 19 additions & 0 deletions go/internal/runner/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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{},
Expand Down
2 changes: 1 addition & 1 deletion go/internal/runner/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
19 changes: 19 additions & 0 deletions go/internal/runnerhub/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions go/internal/runnerhub/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading