diff --git a/edge-server/internal/adapters/acp/acp_client.go b/edge-server/internal/adapters/acp/acp_client.go index d2acac9ad..f65195abb 100644 --- a/edge-server/internal/adapters/acp/acp_client.go +++ b/edge-server/internal/adapters/acp/acp_client.go @@ -28,11 +28,15 @@ import ( // against (bump discipline: update on SDK upgrades). const acpSDKVersion = "v0.13.5" -// errACPEndpointNotWired is returned by client handler methods that the spike -// deliberately leaves unwired (fs/terminal frame design): the agent receives -// a JSON-RPC error instead of a silent hang. session/request_permission no -// longer goes through this sentinel — it is bridged to the Edge approval -// chain via adapters.PermissionDecisionBroker. +// errACPEndpointNotWired is returned by client handler methods that the +// spike deliberately leaves unwired (fs/terminal execution — the frame +// design + workspace allowlist gate already lives in acp_frames.go): the +// agent receives a JSON-RPC error instead of a silent hang. Frame-gate +// rejections wrap it too (unwiredFrameError), so the STUB INVENTORY +// contract — every answer an unwired endpoint can give is observable as +// not-wired — holds on both branches. session/request_permission no longer +// goes through this sentinel — it is bridged to the Edge approval chain +// via adapters.PermissionDecisionBroker. var errACPEndpointNotWired = errors.New("acp: endpoint not wired (TODO #1743 (follow-up of #1404): frame design)") // acpClientHandler is the coder/acp-go-sdk client side. The SDK dispatches @@ -71,6 +75,14 @@ type acpClientHandler struct { // or $/cancel_request). runCtx context.Context + // allowlist is the workspace path gate for fs/terminal frames + // (acp_frames.go, #1743 item 1): built from the session workdir in + // runACPSession. Every fs/terminal stub runs its inbound request + // through the frame builders, so a path outside the workspace is + // rejected before the not-wired answer. An empty root rejects every + // path (fail-closed). + allowlist *workspaceAllowlist + // permSeq generates unique per-run broker request ids. permSeq atomic.Uint64 } @@ -79,8 +91,14 @@ type acpClientHandler struct { // (the SDK dispatch calls these methods directly). var _ acp.Client = (*acpClientHandler)(nil) -func newACPClientHandler(emitter EventEmitter, run store.Run, broker *adapters.PermissionDecisionBroker, runCtx context.Context) *acpClientHandler { - return &acpClientHandler{emitter: emitter, run: run, broker: broker, runCtx: runCtx} +func newACPClientHandler(emitter EventEmitter, run store.Run, broker *adapters.PermissionDecisionBroker, runCtx context.Context, workspaceRoot string) *acpClientHandler { + return &acpClientHandler{ + emitter: emitter, + run: run, + broker: broker, + runCtx: runCtx, + allowlist: newWorkspaceAllowlist(workspaceRoot), + } } // SessionUpdate handles the agent's session/update notification: typed @@ -255,15 +273,16 @@ func firstPermissionOption(options []acp.PermissionOption, kinds ...acp.Permissi // ── Unwired endpoint stubs (#1404) ──────────────────────────────────────── // // STUB INVENTORY (single source of the "not wired" list — update both the -// methods below and TestUnwiredACPEndpointsFailClosed when #1743 lands): +// methods below and TestUnwiredACPEndpointsFailClosed when an endpoint is +// wired): // -// fs/read_text_file → ReadTextFile (fs frame design + allowlist) -// fs/write_text_file → WriteTextFile (fs frame design + allowlist) -// terminal/create → CreateTerminal (terminal frame design) -// terminal/kill → KillTerminal (terminal frame design) -// terminal/output → TerminalOutput (terminal frame design) -// terminal/release → ReleaseTerminal (terminal frame design) -// terminal/wait_for_exit → WaitForTerminalExit (terminal frame design) +// fs/read_text_file → ReadTextFile (execution not wired) +// fs/write_text_file → WriteTextFile (execution not wired) +// terminal/create → CreateTerminal (execution not wired) +// terminal/kill → KillTerminal (execution not wired) +// terminal/output → TerminalOutput (execution not wired) +// terminal/release → ReleaseTerminal (execution not wired) +// terminal/wait_for_exit → WaitForTerminalExit (execution not wired) // // Wired endpoints (not stubs): session/update (SessionUpdate) and // session/request_permission (RequestPermission → Edge approval chain). @@ -271,54 +290,106 @@ func firstPermissionOption(options []acp.PermissionOption, kinds ...acp.Permissi // Every stub fails closed with errACPEndpointNotWired — a JSON-RPC error the // agent can surface — never a silent hang, never a fake success. // -// #1743 removed the fs/terminal capability advertisement from initialize -// (runACPSession): advertising a capability whose endpoints can only answer -// with errors would invite the agent to depend on a broken surface. The -// stubs stay fail-closed as the last line of defense until the fs/terminal -// frame design (+ workspace allowlist) actually wires them. +// #1743 item 1 landed the two preconditions for wiring (acp_frames.go): +// - capabilities shrink: initialize no longer advertises fs/terminal +// (runACPSession) — advertising a capability whose endpoints can only +// answer with errors would invite the agent to depend on a broken +// surface; +// - frame design + workspace allowlist: every stub builds its edge-side +// frame through the allowlist gate before answering, so a path outside +// the session workspace is rejected even before the not-wired error +// (gate rejections still wrap errACPEndpointNotWired). +// +// What remains is real execution behind the gate (#1743 item 3, real-run +// verification, requires approval) — until then the stubs stay fail-closed +// as the last line of defense. // // ReadTextFile handles fs/read_text_file. func (h *acpClientHandler) ReadTextFile(ctx context.Context, params acp.ReadTextFileRequest) (acp.ReadTextFileResponse, error) { - return acp.ReadTextFileResponse{}, fsEndpointError("fs/read_text_file", params.Path) + frame, err := buildReadTextFileFrame(h.allowlist, params) + if err != nil { + return acp.ReadTextFileResponse{}, unwiredFrameError(err) + } + return acp.ReadTextFileResponse{}, fsEndpointError(frame.Method, frame.Path) } // WriteTextFile handles fs/write_text_file. Stub, see the STUB INVENTORY above. func (h *acpClientHandler) WriteTextFile(ctx context.Context, params acp.WriteTextFileRequest) (acp.WriteTextFileResponse, error) { - return acp.WriteTextFileResponse{}, fsEndpointError("fs/write_text_file", params.Path) + frame, err := buildWriteTextFileFrame(h.allowlist, params) + if err != nil { + return acp.WriteTextFileResponse{}, unwiredFrameError(err) + } + return acp.WriteTextFileResponse{}, fsEndpointError(frame.Method, frame.Path) } -func fsEndpointError(method, path string) error { - return fmt.Errorf("acp: %s %q not wired (TODO #1743 (follow-up of #1404): Edge fs frame design + allowlist): %w", - method, path, errACPEndpointNotWired) +// fsEndpointError is the fail-closed answer for an fs frame that passed the +// workspace allowlist gate: the frame is valid but nothing executes it yet. +func fsEndpointError(method, normalizedPath string) error { + return fmt.Errorf("acp: %s %q passed the workspace allowlist but execution is not wired (TODO #1743 item 3: real-run verification): %w", + method, normalizedPath, errACPEndpointNotWired) } -// CreateTerminal handles terminal/create. Stub, see the STUB INVENTORY above. +// unwiredFrameError is the fail-closed answer when the frame gate rejects a +// request (path outside the workspace, malformed frame): the gate rejection +// stays observable via errACPPathOutsideWorkspace / errACPMalformedFrame, +// and the STUB INVENTORY contract holds because the error also wraps +// errACPEndpointNotWired — nothing is executed for these endpoints. +func unwiredFrameError(gateErr error) error { + return fmt.Errorf("acp: frame rejected before execution: %w (endpoint unwired: %w)", gateErr, errACPEndpointNotWired) +} + +// CreateTerminal handles terminal/create. Stub, see the STUB INVENTORY +// above; an explicit cwd passes the workspace allowlist gate first. func (h *acpClientHandler) CreateTerminal(ctx context.Context, params acp.CreateTerminalRequest) (acp.CreateTerminalResponse, error) { - return acp.CreateTerminalResponse{}, terminalEndpointError("terminal/create") + frame, err := buildCreateTerminalFrame(h.allowlist, params) + if err != nil { + return acp.CreateTerminalResponse{}, unwiredFrameError(err) + } + return acp.CreateTerminalResponse{}, terminalEndpointError(frame.Method) } // KillTerminal handles terminal/kill. Stub, see the STUB INVENTORY above. func (h *acpClientHandler) KillTerminal(ctx context.Context, params acp.KillTerminalRequest) (acp.KillTerminalResponse, error) { - return acp.KillTerminalResponse{}, terminalEndpointError("terminal/kill") + frame, err := buildTerminalIDFrame(acpMethodKillTerminal, params.TerminalId) + if err != nil { + return acp.KillTerminalResponse{}, unwiredFrameError(err) + } + return acp.KillTerminalResponse{}, terminalEndpointError(frame.Method) } // TerminalOutput handles terminal/output. Stub, see the STUB INVENTORY above. func (h *acpClientHandler) TerminalOutput(ctx context.Context, params acp.TerminalOutputRequest) (acp.TerminalOutputResponse, error) { - return acp.TerminalOutputResponse{}, terminalEndpointError("terminal/output") + frame, err := buildTerminalIDFrame(acpMethodTerminalOutput, params.TerminalId) + if err != nil { + return acp.TerminalOutputResponse{}, unwiredFrameError(err) + } + return acp.TerminalOutputResponse{}, terminalEndpointError(frame.Method) } // ReleaseTerminal handles terminal/release. Stub, see the STUB INVENTORY above. func (h *acpClientHandler) ReleaseTerminal(ctx context.Context, params acp.ReleaseTerminalRequest) (acp.ReleaseTerminalResponse, error) { - return acp.ReleaseTerminalResponse{}, terminalEndpointError("terminal/release") + frame, err := buildTerminalIDFrame(acpMethodReleaseTerminal, params.TerminalId) + if err != nil { + return acp.ReleaseTerminalResponse{}, unwiredFrameError(err) + } + return acp.ReleaseTerminalResponse{}, terminalEndpointError(frame.Method) } -// WaitForTerminalExit handles terminal/wait_for_exit. See ReadTextFile for the TODO. +// WaitForTerminalExit handles terminal/wait_for_exit. Stub, see the STUB +// INVENTORY above. func (h *acpClientHandler) WaitForTerminalExit(ctx context.Context, params acp.WaitForTerminalExitRequest) (acp.WaitForTerminalExitResponse, error) { - return acp.WaitForTerminalExitResponse{}, terminalEndpointError("terminal/wait_for_exit") + frame, err := buildTerminalIDFrame(acpMethodWaitTerminalExit, params.TerminalId) + if err != nil { + return acp.WaitForTerminalExitResponse{}, unwiredFrameError(err) + } + return acp.WaitForTerminalExitResponse{}, terminalEndpointError(frame.Method) } +// terminalEndpointError is the fail-closed answer for a terminal frame that +// passed the frame gate: the frame is valid but nothing executes it yet. func terminalEndpointError(method string) error { - return fmt.Errorf("acp: %s not wired (TODO #1743 (follow-up of #1404): Edge terminal frame design): %w", method, errACPEndpointNotWired) + return fmt.Errorf("acp: %s frame accepted but execution is not wired (TODO #1743 item 3: real-run verification): %w", + method, errACPEndpointNotWired) } // runACPSession runs one ACP turn with the SDK client runtime: initialize @@ -350,7 +421,7 @@ func runACPSession(ctx context.Context, stdout io.Reader, stdin io.Writer, emitt return adapters.NewNonRecoverableParseError(fmt.Errorf("acp: workdir required for session/new (got %q)", rc.WorkDir)) } - handler := newACPClientHandler(emitter, run, broker, ctx) + handler := newACPClientHandler(emitter, run, broker, ctx, rc.WorkDir) conn := acp.NewClientSideConnection(handler, stdin, stdout) conn.SetLogger(slog.With("component", "acp-sdk", "sdk", acpSDKVersion, "run_id", run.ID)) diff --git a/edge-server/internal/adapters/acp/acp_client_test.go b/edge-server/internal/adapters/acp/acp_client_test.go index f060082a8..9f10c97a8 100644 --- a/edge-server/internal/adapters/acp/acp_client_test.go +++ b/edge-server/internal/adapters/acp/acp_client_test.go @@ -32,10 +32,15 @@ import ( // every deliberately unwired ACP endpoint (#1404 fs/terminal frame design) // must answer with an error wrapping errACPEndpointNotWired — a JSON-RPC // error the agent can surface — and must never return a nil error (silent -// hang from the agent's perspective) or a fabricated success response. When -// #1404 wires an endpoint, remove it from this table and from the inventory. +// hang from the agent's perspective) or a fabricated success response. +// +// This table exercises the frame-gate rejection branch (#1743 item 1): with +// no workspace configured, every path is rejected by the allowlist and +// every terminal frame is malformed (empty requests) — both gate errors +// still wrap errACPEndpointNotWired (unwiredFrameError). When #1743 item 3 +// wires an endpoint, remove it from this table and from the inventory. func TestUnwiredACPEndpointsFailClosed(t *testing.T) { - handler := newACPClientHandler(nil, store.Run{ID: "run_test"}, nil, context.Background()) + handler := newACPClientHandler(nil, store.Run{ID: "run_test"}, nil, context.Background(), "") ctx := context.Background() stubs := []struct { @@ -85,6 +90,262 @@ func TestUnwiredACPEndpointsFailClosed(t *testing.T) { } } +// TestUnwiredACPEndpointsFailClosedInsideWorkspace covers the second +// fail-closed branch: requests whose frames pass the allowlist gate still +// end in errACPEndpointNotWired because no execution is wired behind the +// gate yet (#1743 item 3 pending). Together with +// TestUnwiredACPEndpointsFailClosed this locks both answers an unwired +// stub can give. +func TestUnwiredACPEndpointsFailClosedInsideWorkspace(t *testing.T) { + handler := newACPClientHandler(nil, store.Run{ID: "run_test"}, nil, context.Background(), "/workspace") + ctx := context.Background() + + inWorkspacePath := "/workspace/src/main.go" + inWorkspaceCwd := "/workspace/scripts" + + stubs := []struct { + name string + call func() error + }{ + {"fs/read_text_file", func() error { + _, err := handler.ReadTextFile(ctx, acp.ReadTextFileRequest{Path: inWorkspacePath}) + return err + }}, + {"fs/write_text_file", func() error { + _, err := handler.WriteTextFile(ctx, acp.WriteTextFileRequest{Path: inWorkspacePath, Content: "data"}) + return err + }}, + {"terminal/create", func() error { + _, err := handler.CreateTerminal(ctx, acp.CreateTerminalRequest{Command: "go", Cwd: &inWorkspaceCwd}) + return err + }}, + {"terminal/kill", func() error { + _, err := handler.KillTerminal(ctx, acp.KillTerminalRequest{TerminalId: "term-1"}) + return err + }}, + {"terminal/output", func() error { + _, err := handler.TerminalOutput(ctx, acp.TerminalOutputRequest{TerminalId: "term-1"}) + return err + }}, + {"terminal/release", func() error { + _, err := handler.ReleaseTerminal(ctx, acp.ReleaseTerminalRequest{TerminalId: "term-1"}) + return err + }}, + {"terminal/wait_for_exit", func() error { + _, err := handler.WaitForTerminalExit(ctx, acp.WaitForTerminalExitRequest{TerminalId: "term-1"}) + return err + }}, + } + + for _, stub := range stubs { + t.Run(stub.name, func(t *testing.T) { + err := stub.call() + if err == nil { + t.Fatalf("%s returned nil error — stub must fail closed, not hang", stub.name) + } + if !errors.Is(err, errACPEndpointNotWired) { + t.Fatalf("%s error = %v, want errACPEndpointNotWired", stub.name, err) + } + // The frame passed the gate, so no gate sentinel may leak in. + if errors.Is(err, errACPPathOutsideWorkspace) || errors.Is(err, errACPMalformedFrame) { + t.Fatalf("%s error = %v, wraps a gate sentinel despite an in-workspace frame", stub.name, err) + } + }) + } +} + +// TestFsTerminalStubsEnforceAllowlistGate drives the stubs with a +// configured workspace: paths outside the workspace and malformed frames +// are rejected at the gate with the gate sentinel observable, and the +// answer still wraps errACPEndpointNotWired (nothing executes for unwired +// endpoints, whichever branch fails). +func TestFsTerminalStubsEnforceAllowlistGate(t *testing.T) { + handler := newACPClientHandler(nil, store.Run{ID: "run_gate"}, nil, context.Background(), "/workspace") + ctx := context.Background() + + outsideCwd := "/tmp" + rejections := []struct { + name string + call func() error + gateSentinel error + }{ + {"fs/read_text_file outside workspace", func() error { + _, err := handler.ReadTextFile(ctx, acp.ReadTextFileRequest{Path: "/etc/passwd"}) + return err + }, errACPPathOutsideWorkspace}, + {"fs/write_text_file traversal escape", func() error { + _, err := handler.WriteTextFile(ctx, acp.WriteTextFileRequest{Path: "/workspace/../secret.txt", Content: "x"}) + return err + }, errACPPathOutsideWorkspace}, + {"terminal/create cwd outside workspace", func() error { + _, err := handler.CreateTerminal(ctx, acp.CreateTerminalRequest{Command: "sh", Cwd: &outsideCwd}) + return err + }, errACPPathOutsideWorkspace}, + {"terminal/create without command", func() error { + _, err := handler.CreateTerminal(ctx, acp.CreateTerminalRequest{}) + return err + }, errACPMalformedFrame}, + {"terminal/kill without terminalId", func() error { + _, err := handler.KillTerminal(ctx, acp.KillTerminalRequest{}) + return err + }, errACPMalformedFrame}, + } + + for _, rejection := range rejections { + t.Run(rejection.name, func(t *testing.T) { + err := rejection.call() + if err == nil { + t.Fatalf("%s returned nil error — gate rejection must fail closed", rejection.name) + } + if !errors.Is(err, rejection.gateSentinel) { + t.Errorf("%s error = %v, want wrapping %v", rejection.name, err, rejection.gateSentinel) + } + if !errors.Is(err, errACPEndpointNotWired) { + t.Errorf("%s error = %v, must keep wrapping errACPEndpointNotWired (STUB INVENTORY contract)", rejection.name, err) + } + }) + } +} + +// TestWorkspaceAllowlistValidatePath locks the fail-closed containment +// rules of the frame gate (#1743 item 1): only paths resolving inside the +// workspace root pass; traversal, outside paths, relative paths, empty +// inputs, and adjacent-prefix directories all reject with +// errACPPathOutsideWorkspace. +func TestWorkspaceAllowlistValidatePath(t *testing.T) { + cases := []struct { + name string + root string + path string + allowed bool + }{ + {"nested file", "/workspace", "/workspace/src/main.go", true}, + {"root itself", "/workspace", "/workspace", true}, + {"root with trailing slash", "/workspace/", "/workspace/file.txt", true}, + {"filesystem root allows descendants", "/", "/etc/passwd", true}, + {"filesystem root itself", "/", "/", true}, + {"windows backslashes", `C:\work`, `C:\work\file.txt`, true}, + {"windows drive letter case", `C:\work`, `c:/work/file.txt`, true}, + {"windows directory component case-insensitive", `C:\Work`, `c:\work\File.txt`, true}, + {"windows root itself case-insensitive", `C:\Work`, `c:\WORK`, true}, + {"windows drive root allows descendants", "C:/", "c:/work/file.txt", true}, + {"windows drive root rejects other drive", "C:/", "D:/work/file.txt", false}, + {"windows adjacent prefix stays rejected", `C:\Work`, `C:\Work-evil\file.txt`, false}, + {"path with interior space", "/workspace", "/workspace/my docs/file.txt", true}, + {"file with trailing space inside workspace", "/workspace", "/workspace/readme.txt ", true}, + {"leading space path rejected", "/workspace", " /workspace/file.txt", false}, + {"trailing-space workdir rejects trimmed bypass", "/work ", "/work/secret", false}, + {"traversal escapes root", "/workspace", "/workspace/../etc/passwd", false}, + {"outside absolute path", "/workspace", "/etc/passwd", false}, + {"adjacent prefix directory", "/workspace", "/workspace-evil/file.txt", false}, + {"relative path", "/workspace", "src/main.go", false}, + {"empty path", "/workspace", "", false}, + {"whitespace-only path", "/workspace", " ", false}, + {"empty root rejects everything", "", "/workspace/file.txt", false}, + {"windows traversal", `C:\work`, `C:\work\..\secret.txt`, false}, + {"different windows drive", `C:\work`, `D:\work\file.txt`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + allowlist := newWorkspaceAllowlist(tc.root) + err := allowlist.validatePath(tc.path) + if tc.allowed { + if err != nil { + t.Fatalf("validatePath(%q) under root %q = %v, want allowed", tc.path, tc.root, err) + } + } else { + if err == nil { + t.Fatalf("validatePath(%q) under root %q = nil, want rejection", tc.path, tc.root) + } + if !errors.Is(err, errACPPathOutsideWorkspace) { + t.Fatalf("validatePath(%q) error = %v, want wrapping errACPPathOutsideWorkspace", tc.path, err) + } + } + if got := allowlist.AllowsPath(tc.path); got != tc.allowed { + t.Errorf("AllowsPath(%q) = %v, want %v", tc.path, got, tc.allowed) + } + }) + } + + // An absent allowlist (no workspace at all) rejects too — fail-closed. + var absentAllowlist *workspaceAllowlist + if err := absentAllowlist.validatePath("/workspace/file.txt"); !errors.Is(err, errACPPathOutsideWorkspace) { + t.Errorf("nil allowlist validatePath = %v, want wrapping errACPPathOutsideWorkspace", err) + } +} + +// TestFsTerminalFramesCarryNormalizedFields locks the frame side of the +// design: frames that pass the gate carry normalized fields a future +// executor must use (never the raw request path), with schema optionals +// passed through. +func TestFsTerminalFramesCarryNormalizedFields(t *testing.T) { + allowlist := newWorkspaceAllowlist(`C:\work`) + + readFrame, err := buildReadTextFileFrame(allowlist, acp.ReadTextFileRequest{Path: `c:\work\src\main.go`}) + if err != nil { + t.Fatalf("buildReadTextFileFrame: %v", err) + } + if readFrame.Method != acpMethodReadTextFile || readFrame.Path != "C:/work/src/main.go" { + t.Errorf("read frame = %+v, want method %s path C:/work/src/main.go", readFrame, acpMethodReadTextFile) + } + + line, limit := 3, 25 + windowFrame, err := buildReadTextFileFrame(allowlist, acp.ReadTextFileRequest{Path: `C:\work\notes.md`, Line: &line, Limit: &limit}) + if err != nil { + t.Fatalf("buildReadTextFileFrame(window): %v", err) + } + if windowFrame.Line != &line || windowFrame.Limit != &limit { + t.Errorf("read window frame = %+v, want Line/Limit passthrough", windowFrame) + } + + writeFrame, err := buildWriteTextFileFrame(allowlist, acp.WriteTextFileRequest{Path: `C:\work\out.txt`, Content: "data"}) + if err != nil { + t.Fatalf("buildWriteTextFileFrame: %v", err) + } + if writeFrame.Method != acpMethodWriteTextFile || writeFrame.Path != "C:/work/out.txt" || writeFrame.Content != "data" { + t.Errorf("write frame = %+v, want normalized path and content passthrough", writeFrame) + } + + createFrame, err := buildCreateTerminalFrame(allowlist, acp.CreateTerminalRequest{ + Command: "go", + Args: []string{"test", "./..."}, + Cwd: stringPointer(`c:\work\scripts`), + Env: []acp.EnvVariable{{Name: "GOFLAGS", Value: "-short"}, {Name: "GOFLAGS", Value: "-v"}}, + }) + if err != nil { + t.Fatalf("buildCreateTerminalFrame: %v", err) + } + if createFrame.Cwd != "C:/work/scripts" { + t.Errorf("create frame cwd = %q, want normalized C:/work/scripts", createFrame.Cwd) + } + if len(createFrame.Args) != 2 || createFrame.Args[0] != "test" { + t.Errorf("create frame args = %v, want [test ./...]", createFrame.Args) + } + // Later env entries win (process-env semantics). + if createFrame.Env["GOFLAGS"] != "-v" { + t.Errorf("create frame env GOFLAGS = %q, want -v (last entry wins)", createFrame.Env["GOFLAGS"]) + } + + defaultCwdFrame, err := buildCreateTerminalFrame(allowlist, acp.CreateTerminalRequest{Command: "go"}) + if err != nil { + t.Fatalf("buildCreateTerminalFrame(no cwd): %v", err) + } + if defaultCwdFrame.Cwd != "" { + t.Errorf("create frame cwd = %q, want empty (defaults to the session workdir at execution time)", defaultCwdFrame.Cwd) + } + + killFrame, err := buildTerminalIDFrame(acpMethodKillTerminal, "term-1") + if err != nil { + t.Fatalf("buildTerminalIDFrame: %v", err) + } + if killFrame.Method != acpMethodKillTerminal || killFrame.TerminalID != "term-1" { + t.Errorf("terminal id frame = %+v, want method %s id term-1", killFrame, acpMethodKillTerminal) + } +} + +// stringPointer returns a pointer to s for building optional SDK fields. +func stringPointer(s string) *string { return &s } + // fakeACPAgent simulates an ACP agent over two pipes. It reads client // requests from reqR (the client's stdin side) and writes responses and // notifications to respW (the client's stdout side). The wire format is real diff --git a/edge-server/internal/adapters/acp/acp_frames.go b/edge-server/internal/adapters/acp/acp_frames.go new file mode 100644 index 000000000..3fb34cfa9 --- /dev/null +++ b/edge-server/internal/adapters/acp/acp_frames.go @@ -0,0 +1,291 @@ +// Package adapters — ACP fs/terminal frame design + workspace allowlist +// (#1743 item 1, follow-up of #1404). +// +// The seven fs/terminal endpoints of acp.Client (see the STUB INVENTORY in +// acp_client.go) are still unwired: nothing in this package executes real +// filesystem or terminal I/O. Before they can be wired, every inbound +// request needs two things, defined here: +// +// 1. A frame — the edge-side representation of the request, i.e. what a +// future executor consumes. Building a frame from an SDK request is a +// pure transformation: normalized fields, no I/O, no side effects. +// +// 2. The workspace allowlist gate — every path a frame carries must +// resolve inside the workspace root the run was started with +// (runACPSession passes the session workdir). The gate is pure string +// containment on normalized paths (no filesystem access) and fails +// closed: an empty root, an empty or relative path, or any ".." +// escape rejects. +// +// Layering in the unwired stubs (acp_client.go): +// +// SDK request → frame builder (allowlist gate) +// → gate rejects: JSON-RPC error wrapping errACPPathOutsideWorkspace +// AND errACPEndpointNotWired (unwiredFrameError) +// → gate accepts: JSON-RPC error wrapping errACPEndpointNotWired +// until real execution is approved +// +// Real fs/terminal execution is deliberately NOT part of this file — it is +// #1743 item 3 (real-run verification, requires approval). The future +// executor must re-check resolved paths (e.g. after symlink resolution) +// against the allowlist before any I/O; the pure containment check here is +// the first, not the only, line of defense. +package acp + +import ( + "errors" + "fmt" + "path" + "strings" + + "github.com/coder/acp-go-sdk" +) + +// ACP method names for the unwired fs/terminal endpoints (SSOT shared by +// the STUB INVENTORY stubs in acp_client.go and the frame builders below). +const ( + acpMethodReadTextFile = "fs/read_text_file" + acpMethodWriteTextFile = "fs/write_text_file" + acpMethodCreateTerminal = "terminal/create" + acpMethodKillTerminal = "terminal/kill" + acpMethodTerminalOutput = "terminal/output" + acpMethodReleaseTerminal = "terminal/release" + acpMethodWaitTerminalExit = "terminal/wait_for_exit" +) + +// errACPPathOutsideWorkspace is the fail-closed rejection returned by the +// workspace allowlist gate when a frame carries a path that does not +// resolve inside the session's workspace root. +var errACPPathOutsideWorkspace = errors.New("acp: path outside workspace allowlist") + +// errACPMalformedFrame is the fail-closed rejection returned when a frame +// cannot be built from an SDK request (missing required fields). +var errACPMalformedFrame = errors.New("acp: malformed frame") + +// workspaceAllowlist is the path boundary for fs/terminal frames: a frame +// is only built when every path it carries resolves inside the workspace +// root the run was started with. +// +// The check is pure string containment on normalized paths — no filesystem +// access, no symlink resolution — so the gate is side-effect free and +// fails closed on any ambiguity. +type workspaceAllowlist struct { + // workspaceRoot is the normalized absolute workspace root; "" means + // no workspace is configured and every path is rejected. + workspaceRoot string +} + +// newWorkspaceAllowlist builds the allowlist for a session workspace root. +// An empty root yields an allowlist that rejects every path (fail-closed). +func newWorkspaceAllowlist(workspaceRoot string) *workspaceAllowlist { + return &workspaceAllowlist{workspaceRoot: normalizeWorkspacePath(workspaceRoot)} +} + +// AllowsPath reports whether candidate resolves inside the workspace root. +func (a *workspaceAllowlist) AllowsPath(candidate string) bool { + return a.validatePath(candidate) == nil +} + +// validatePath returns a fail-closed error wrapping +// errACPPathOutsideWorkspace unless rawPath resolves inside the workspace +// root. Reject rules: absent allowlist, unconfigured root, empty path, +// relative path (ACP requires absolute paths), and any path whose cleaned +// form is neither the root itself nor nested under it. +func (a *workspaceAllowlist) validatePath(rawPath string) error { + if a == nil || a.workspaceRoot == "" { + return fmt.Errorf("%w: no workspace configured (path %q)", errACPPathOutsideWorkspace, rawPath) + } + normalized := normalizeWorkspacePath(rawPath) + if normalized == "" { + return fmt.Errorf("%w: empty path", errACPPathOutsideWorkspace) + } + if !normalizedPathIsAbsolute(normalized) { + return fmt.Errorf("%w: relative path %q (ACP requires absolute paths)", errACPPathOutsideWorkspace, rawPath) + } + if !pathInsideWorkspace(normalized, a.workspaceRoot) { + return fmt.Errorf("%w: %q does not resolve under workspace %q", errACPPathOutsideWorkspace, rawPath, a.workspaceRoot) + } + return nil +} + +// normalizeWorkspacePath reduces a client-supplied path to the canonical +// slash-separated form the allowlist compares: backslashes become slashes +// (Windows inputs), "." and ".." segments are resolved by path.Clean, and +// a leading drive letter is upper-cased so containment matches the case +// insensitivity of Windows roots ("c:/work" ≡ "C:/work"). Pure string +// handling — no filesystem access. +// +// Whitespace-only input is rejected (""), but every character of a +// non-empty path is preserved: trimming would rewrite a legitimate +// trailing-space workdir ("/work ") into "/work" and let the gate accept +// paths outside the real root. +func normalizeWorkspacePath(rawPath string) string { + if strings.TrimSpace(rawPath) == "" { + return "" + } + slashed := strings.ReplaceAll(rawPath, "\\", "/") + cleaned := path.Clean(slashed) + if len(cleaned) >= 2 && cleaned[1] == ':' { + drive := cleaned[0] + if 'a' <= drive && drive <= 'z' { + cleaned = strings.ToUpper(string(drive)) + cleaned[1:] + } + // path.Clean drops the trailing separator of drive roots + // ("C:/" → "C:"); restore it so drive roots stay canonical + // filesystem roots for the containment check. + if len(cleaned) == 2 && strings.HasSuffix(slashed, "/") { + cleaned += "/" + } + } + return cleaned +} + +// normalizedPathIsAbsolute reports whether a normalized path is absolute: +// POSIX ("/...") or Windows drive-rooted ("X:/..."). +func normalizedPathIsAbsolute(normalized string) bool { + return strings.HasPrefix(normalized, "/") || isWindowsDrivePath(normalized) +} + +// isWindowsDrivePath reports whether a normalized path is Windows +// drive-rooted ("X:/..."). +func isWindowsDrivePath(normalized string) bool { + return len(normalized) >= 3 && normalized[1] == ':' && normalized[2] == '/' +} + +// pathInsideWorkspace reports whether candidate is the root itself or a +// descendant of it, using component-boundary containment so adjacent +// prefixes ("/work" vs "/work-evil") never match. Filesystem roots +// ("/", "C:/") act as boundaries without appending a duplicate separator, +// and Windows drive paths compare case-insensitively (NTFS/FAT semantics) +// while keeping the boundary check. Pure string handling — no filesystem +// access. +func pathInsideWorkspace(candidate, root string) bool { + if isWindowsDrivePath(candidate) && isWindowsDrivePath(root) { + boundary := root + if !strings.HasSuffix(boundary, "/") { + boundary += "/" + } + return strings.EqualFold(candidate, root) || + strings.HasPrefix(strings.ToLower(candidate), strings.ToLower(boundary)) + } + if candidate == root { + return true + } + if strings.HasSuffix(root, "/") { + return strings.HasPrefix(candidate, root) + } + return strings.HasPrefix(candidate, root+"/") +} + +// acpFsFrame is the edge-side frame for the fs/* endpoints: the validated +// representation a future executor consumes. Building one never touches +// the filesystem. +type acpFsFrame struct { + // Method is the ACP method the frame answers (acpMethodReadTextFile + // or acpMethodWriteTextFile). + Method string + // Path is the normalized absolute file path, allowlist-validated. + // Executors must use this field, never the raw request path. + Path string + // Content carries the bytes to write (write frames only). + Content string + // Line / Limit bound the read window (read frames only); nil means + // "from the start" / "no limit" per the ACP schema. + Line *int + Limit *int +} + +// buildReadTextFileFrame validates an fs/read_text_file request against +// the workspace allowlist and returns its edge-side frame. +func buildReadTextFileFrame(allowlist *workspaceAllowlist, req acp.ReadTextFileRequest) (acpFsFrame, error) { + if err := allowlist.validatePath(req.Path); err != nil { + return acpFsFrame{}, fmt.Errorf("%s: %w", acpMethodReadTextFile, err) + } + return acpFsFrame{ + Method: acpMethodReadTextFile, + Path: normalizeWorkspacePath(req.Path), + Line: req.Line, + Limit: req.Limit, + }, nil +} + +// buildWriteTextFileFrame validates an fs/write_text_file request against +// the workspace allowlist and returns its edge-side frame. +func buildWriteTextFileFrame(allowlist *workspaceAllowlist, req acp.WriteTextFileRequest) (acpFsFrame, error) { + if err := allowlist.validatePath(req.Path); err != nil { + return acpFsFrame{}, fmt.Errorf("%s: %w", acpMethodWriteTextFile, err) + } + return acpFsFrame{ + Method: acpMethodWriteTextFile, + Path: normalizeWorkspacePath(req.Path), + Content: req.Content, + }, nil +} + +// acpTerminalFrame is the edge-side frame for the terminal/* endpoints. +type acpTerminalFrame struct { + // Method is the ACP method the frame answers (one of the terminal + // acpMethod* constants). + Method string + // TerminalID addresses an existing terminal (kill/output/release/ + // wait_for_exit frames). + TerminalID string + // Command / Args / Env describe the process to spawn (create frames). + Command string + Args []string + Env map[string]string + // Cwd is the normalized working directory (create frames); "" means + // "the session workdir", which is the workspace root itself and thus + // inside the allowlist by construction (runACPSession requires it + // for session/new). + Cwd string + // OutputByteLimit bounds retained output (create frames). + OutputByteLimit *int +} + +// buildCreateTerminalFrame validates a terminal/create request and returns +// its edge-side frame. An explicit cwd must resolve inside the workspace +// allowlist; an absent cwd stays "" and defaults to the session workdir at +// execution time. +func buildCreateTerminalFrame(allowlist *workspaceAllowlist, req acp.CreateTerminalRequest) (acpTerminalFrame, error) { + if strings.TrimSpace(req.Command) == "" { + return acpTerminalFrame{}, fmt.Errorf("%s: %w: command is required", acpMethodCreateTerminal, errACPMalformedFrame) + } + frame := acpTerminalFrame{ + Method: acpMethodCreateTerminal, + Command: req.Command, + Args: append([]string(nil), req.Args...), + Env: acpEnvVariablesToMap(req.Env), + OutputByteLimit: req.OutputByteLimit, + } + if req.Cwd != nil && strings.TrimSpace(*req.Cwd) != "" { + if err := allowlist.validatePath(*req.Cwd); err != nil { + return acpTerminalFrame{}, fmt.Errorf("%s: %w", acpMethodCreateTerminal, err) + } + frame.Cwd = normalizeWorkspacePath(*req.Cwd) + } + return frame, nil +} + +// buildTerminalIDFrame validates the terminal-addressing endpoints +// (kill/output/release/wait_for_exit), which carry no paths — only a +// non-empty terminalId. +func buildTerminalIDFrame(method, terminalID string) (acpTerminalFrame, error) { + if strings.TrimSpace(terminalID) == "" { + return acpTerminalFrame{}, fmt.Errorf("%s: %w: terminalId is required", method, errACPMalformedFrame) + } + return acpTerminalFrame{Method: method, TerminalID: terminalID}, nil +} + +// acpEnvVariablesToMap flattens the ACP env list into a map (later entries +// win, mirroring process-env semantics). Returns nil for an empty list. +func acpEnvVariablesToMap(variables []acp.EnvVariable) map[string]string { + if len(variables) == 0 { + return nil + } + merged := make(map[string]string, len(variables)) + for _, variable := range variables { + merged[variable.Name] = variable.Value + } + return merged +}