diff --git a/internal/exec/adapter.go b/internal/exec/adapter.go index 7a70610..7114e3d 100644 --- a/internal/exec/adapter.go +++ b/internal/exec/adapter.go @@ -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 " 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 } @@ -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 ` 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) } diff --git a/internal/wire/handler_exec.go b/internal/wire/handler_exec.go index b668a9c..bddbf1d 100644 --- a/internal/wire/handler_exec.go +++ b/internal/wire/handler_exec.go @@ -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 diff --git a/internal/wire/handler_fs.go b/internal/wire/handler_fs.go index c3069fe..a5795e0 100644 --- a/internal/wire/handler_fs.go +++ b/internal/wire/handler_fs.go @@ -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) @@ -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{ @@ -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"