Skip to content
Merged
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
34 changes: 26 additions & 8 deletions internal/exec/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@
// merges stderr into stdout; the adapter preserves that contract.
package exec

import "context"
import (
"context"
"strings"
)

// SessionAdapter is the bridge *Session → wire.Executer. The
// `target` argument is accepted to match the forward-compatible
// Executer signature; on 1.4a the shell does its own cwd
// handling so target is dropped on the floor.
// SessionAdapter is the bridge *Session → wire.Executer.
//
// The `target` argument is the validated, symlink-resolved working
// directory the caller intended the command to run in. When
// non-empty, the adapter prepends an explicit "cd <target>" to
// the command so it executes in that directory regardless of the
// bash session's current state. When empty, the command runs in
// whatever cwd the shell's previous command left behind (backward-
// compatible behaviour).
type SessionAdapter struct {
S *Session
}
Expand All @@ -19,9 +27,19 @@ func NewSessionAdapter(s *Session) *SessionAdapter {
return &SessionAdapter{S: s}
}

// Run forwards to the underlying session. See SessionAdapter
// for the target-discard rationale.
func (a *SessionAdapter) Run(ctx context.Context, _, cmd string) (string, string, int, error) {
// shellQuote wraps s in single quotes, escaping any embedded
// single quotes per POSIX convention.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

// Run forwards to the underlying session, prepending an explicit
// `cd <target>` when target is non-empty so the command executes
// in the directory the caller asked for.
func (a *SessionAdapter) Run(ctx context.Context, target, cmd string) (string, string, int, error) {
if target != "" {
cmd = "cd " + shellQuote(target) + "\n" + cmd
}
return a.S.Run(ctx, cmd)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/wire/handler_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ func (h *ExecHandler) Handle(ctx context.Context, requestID string, payload map[
// ignored. The protocol field is preserved in the decoded
// payload so the upgrade is mechanical.
start := h.now()
stdout, stderr, exit, runErr := h.Shell.Run(callCtx, cwd, p.Command)
stdout, stderr, exit, runErr := h.Shell.Run(callCtx, canonical, p.Command)
duration := h.now().Sub(start)

// Prepend auto-cd warning to stderr if the cwd was outside
Expand Down
10 changes: 7 additions & 3 deletions internal/wire/handler_fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,11 @@ func (fsys *FileSystem) ReadHandler(ctx context.Context, requestID string, paylo
// Stat before reading. We want a distinct file_not_found
// error so the server can decide whether to retry (network
// mount slow to converge) or surface to the operator.
info, err := fsys.IO.Stat(p.Path)
// Use the canonical (symlink-resolved) path — NOT the raw
// p.Path — to close the TOCTOU window where a symlink
// swap between checkAllowed and Stat would bypass the
// allowlist.
info, err := fsys.IO.Stat(canonical)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
fsys.audit("read", canonical, "error", 0)
Expand All @@ -220,7 +224,7 @@ func (fsys *FileSystem) ReadHandler(ctx context.Context, requestID string, paylo
}), nil
}

data, err := fsys.IO.ReadFile(p.Path)
data, err := fsys.IO.ReadFile(canonical)
if err != nil {
fsys.audit("read", canonical, "error", 0)
return NewReadResultEnvelope(requestID, ReadResultPayload{
Expand Down Expand Up @@ -337,7 +341,7 @@ func (fsys *FileSystem) WriteHandler(ctx context.Context, requestID string, payl
// create a directory tree — a write to a non-existent
// parent returns ENOENT and surfaces as io_error.
const filePerm os.FileMode = 0o644
n, err := fsys.IO.WriteFile(p.Path, data, filePerm, mode)
n, err := fsys.IO.WriteFile(canonical, data, filePerm, mode)
if err != nil {
fsys.audit("write", canonical, "error", int64(0))
errCode := "io_error"
Expand Down
Loading