diff --git a/cmd/hermes-node/main_test.go b/cmd/hermes-node/main_test.go index f773e6b..1c1a9c7 100644 --- a/cmd/hermes-node/main_test.go +++ b/cmd/hermes-node/main_test.go @@ -326,12 +326,6 @@ func (fs *fakeServer) URL() string { func (fs *fakeServer) Close() { fs.srv.Close() } -func (fs *fakeServer) setNextResp(env map[string]any) { - fs.mu.Lock() - defer fs.mu.Unlock() - fs.nextResp = env -} - func (fs *fakeServer) handle(w http.ResponseWriter, r *http.Request) { conn, err := fs.upgrader.Upgrade(w, r, nil) if err != nil { 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/exec/shell.go b/internal/exec/shell.go index b4629f6..a0b83f0 100644 --- a/internal/exec/shell.go +++ b/internal/exec/shell.go @@ -72,7 +72,6 @@ type pendingCall struct { // the END marker (or EOF) for a call. type runResult struct { stdout string - stderr string exitSet bool exit int cwdSet bool diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 06f095f..7a54b27 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -33,14 +33,7 @@ const ( LevelError ) -var levelNames = map[Level]string{ - LevelDebug: "DEBUG", - LevelInfo: "INFO", - LevelWarn: "WARN", - LevelError: "ERROR", -} - -// ParseLevel converts a string to a Level. Returns LevelInfo and +// ParseLevel maps a case-insensitive string to a Level. // false when the string is not recognised. func ParseLevel(s string) (Level, bool) { switch strings.ToUpper(strings.TrimSpace(s)) { 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" diff --git a/internal/wire/reconnect_test.go b/internal/wire/reconnect_test.go index 54d890e..05dab91 100644 --- a/internal/wire/reconnect_test.go +++ b/internal/wire/reconnect_test.go @@ -23,7 +23,6 @@ package wire import ( "context" "errors" - "fmt" "net/http" "net/http/httptest" "sync" @@ -675,9 +674,3 @@ func containsSubstr(haystack, needle string) bool { } return false } - -// Ensure unused imports are referenced (the linter complains -// about fmt otherwise). These are intentional — fmt is used in -// the test rig's URL builders and errors is used in the -// dispatch-panic test. -var _ = fmt.Sprintf