From 8bb5c098cc15a1efdb7de036abab2bbc5c666576 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 12 Aug 2026 08:48:32 +0200 Subject: [PATCH] bypass: route approval through 9P instead of Unix socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This refactors the bypass (sandbox escape) mechanism to work with remote toolsrv deployments. Previously, bypass used a Unix socket which only works when toolsrv runs locally. Now: 1. toolsrv exposes bypass/{pending,resolve} 9P files - pending: blocking read returns next bypass request as JSON - resolve: write JSON {id, approved, error} to complete request 2. olliesrv reads bypass/pending in a loop per session - Evaluates requests through the existing bypass broker - Policy check, rate limiting, user notification all stay in olliesrv - Writes approval/denial back to bypass/resolve 3. When approved, toolsrv executes the command directly (no sandbox) - Execution happens on toolsrv's host (local or remote) - Output streams back through the normal tool call path This enables bypass to work when toolsrv is remote: - User sees the approval notification locally - Command executes on the remote host outside its sandbox Architecture: toolsrv (remote) olliesrv (local) ┌─────────────────┐ ┌──────────────────┐ │ sandboxed cmd │ │ bypass broker │ │ ↓ │ │ - policy │ │ bypass.Submit() │──────│ - notification │ │ ↓ │ 9P │ - rate limit │ │ wait for result │←─────│ - user approval │ │ ↓ │ └──────────────────┘ │ execute direct │ └─────────────────┘ --- cmd/olliesrv/internal/bypass/broker.go | 70 ++++++++++ cmd/olliesrv/internal/fs/newroot.go | 13 +- cmd/olliesrv/internal/session/registry.go | 32 +++-- cmd/olliesrv/internal/session/session.go | 58 +++++++++ cmd/toolsrv/internal/bypass/bypass.go | 96 ++++++++++++++ cmd/toolsrv/internal/exec/exec.go | 149 ++++++++++------------ cmd/toolsrv/internal/server/server.go | 32 +++++ toolsrv/client9p.go | 66 ++++++++++ 8 files changed, 417 insertions(+), 99 deletions(-) create mode 100644 cmd/toolsrv/internal/bypass/bypass.go diff --git a/cmd/olliesrv/internal/bypass/broker.go b/cmd/olliesrv/internal/bypass/broker.go index 39336b6..7769b0e 100644 --- a/cmd/olliesrv/internal/bypass/broker.go +++ b/cmd/olliesrv/internal/bypass/broker.go @@ -212,6 +212,76 @@ func (b *Broker) recordDenial(sessionID string) { b.limiterFor(sessionID).Allow() } +// EvaluateRequest evaluates a bypass request from toolsrv and returns the decision. +// This is the 9P-based flow where execution happens in toolsrv, not here. +// Returns (approved, error). If needs user approval, blocks until resolved or timeout. +func (b *Broker) EvaluateRequest(sessionID, cmd, cwd string, env map[string]string, sudo bool) (bool, error) { + // Validate session + if sessionID == "" { + return false, fmt.Errorf("no session identity") + } + if b.sessionValid != nil && !b.sessionValid(sessionID) { + return false, fmt.Errorf("session not found") + } + + // Rate limit check + if !b.checkRateLimit(sessionID) { + b.logf("elevate: rate-limited session=%s cmd=%q", sessionID, cmd) + return false, fmt.Errorf("rate-limited") + } + + // Check policy auto-approve + effective := b.SessionPolicy(sessionID) + if effective.Matches(cmd, "") { + b.logf("elevate: auto-approved cmd=%q session=%s", cmd, sessionID) + return true, nil + } + + // Create pending request for user approval + req := &Request{ + ID: nextRequestID(), + Cmd: cmd, + Cwd: cwd, + Env: env, + SessionID: sessionID, + Sudo: sudo, + CreatedAt: time.Now(), + resolved: make(chan Resolution, 1), + } + + b.mu.Lock() + b.pending[req.ID] = req + b.mu.Unlock() + + // Notify user (D-Bus notification, etc.) + b.notify(req) + b.logf("elevate: pending id=%s cmd=%q session=%s", req.ID, cmd, sessionID) + + // Wait for resolution or timeout + timer := time.NewTimer(RequestTTL) + defer timer.Stop() + + var res Resolution + select { + case res = <-req.Resolved(): + case <-timer.C: + b.mu.Lock() + delete(b.pending, req.ID) + b.mu.Unlock() + res = ResolveTimeout + } + + switch res { + case ResolveApprove, ResolvePersist: + b.logf("elevate: approved id=%s cmd=%q", req.ID, cmd) + return true, nil + default: + b.recordDenial(sessionID) + b.logf("elevate: denied id=%s cmd=%q reason=%s", req.ID, cmd, res) + return false, nil + } +} + // GlobalPolicy returns the policy store for 9P access. func (b *Broker) GlobalPolicy() *PolicyStore { return b.policy diff --git a/cmd/olliesrv/internal/fs/newroot.go b/cmd/olliesrv/internal/fs/newroot.go index e89e473..4239b87 100644 --- a/cmd/olliesrv/internal/fs/newroot.go +++ b/cmd/olliesrv/internal/fs/newroot.go @@ -28,12 +28,13 @@ type Config struct { func NewRoot(cfg Config) *Tree { // Initialize session package session.Init(session.InitConfig{ - Ctx: cfg.Ctx, - Log: cfg.Log, - Sink: cfg.Sink, - AgentsDir: cfg.AgentsDir, - SessionsDir: cfg.SessionsDir, - Yolo: cfg.Yolo, + Ctx: cfg.Ctx, + Log: cfg.Log, + Sink: cfg.Sink, + AgentsDir: cfg.AgentsDir, + SessionsDir: cfg.SessionsDir, + Yolo: cfg.Yolo, + BypassBroker: cfg.BypassBroker, }) // Build the tree from the spec. diff --git a/cmd/olliesrv/internal/session/registry.go b/cmd/olliesrv/internal/session/registry.go index b02f473..962eb43 100644 --- a/cmd/olliesrv/internal/session/registry.go +++ b/cmd/olliesrv/internal/session/registry.go @@ -29,22 +29,24 @@ var ( sessions = make(map[string]*Session) // Package config, set via Init. - serverCtx context.Context - pkgLog *olog.Logger - pkgSink *olog.Sink - pkgAgentsDir string - pkgSessionsDir string - pkgYolo bool + serverCtx context.Context + pkgLog *olog.Logger + pkgSink *olog.Sink + pkgAgentsDir string + pkgSessionsDir string + pkgYolo bool + pkgBypassBroker BypassEvaluator ) // InitConfig configures the session package. type InitConfig struct { - Ctx context.Context - Log *olog.Logger - Sink *olog.Sink - AgentsDir string - SessionsDir string - Yolo bool + Ctx context.Context + Log *olog.Logger + Sink *olog.Sink + AgentsDir string + SessionsDir string + Yolo bool + BypassBroker BypassEvaluator // may be nil if bypass is disabled } // Init initializes the session package with the given configuration. @@ -55,6 +57,7 @@ func Init(cfg InitConfig) { pkgAgentsDir = cfg.AgentsDir pkgSessionsDir = cfg.SessionsDir pkgYolo = cfg.Yolo + pkgBypassBroker = cfg.BypassBroker } // Sessions returns a snapshot of all sessions. @@ -317,6 +320,11 @@ func CreateEmpty(name, remote string) (*Session, error) { sess.Keeper = infra.Keeper sess.SetToolsConn(infra.ToolsConn) + // Start bypass approval loop (if broker is configured) + if pkgBypassBroker != nil { + sess.StartBypassLoop(pkgBypassBroker) + } + mu.Lock() sessions[name] = sess mu.Unlock() diff --git a/cmd/olliesrv/internal/session/session.go b/cmd/olliesrv/internal/session/session.go index 7357125..01335d7 100644 --- a/cmd/olliesrv/internal/session/session.go +++ b/cmd/olliesrv/internal/session/session.go @@ -112,6 +112,64 @@ func (s *Session) SetToolsConn(conn *toolsrv.Conn) { s.toolsConn = conn } +// StartBypassLoop starts a goroutine that reads bypass requests from toolsrv +// and evaluates them via the bypass broker. This should be called after +// SetToolsConn when the toolsrv connection is ready. +func (s *Session) StartBypassLoop(broker BypassEvaluator) { + go s.runBypassLoop(broker) +} + +// BypassEvaluator evaluates bypass requests. Implemented by bypass.Broker. +type BypassEvaluator interface { + EvaluateRequest(sessionID, cmd, cwd string, env map[string]string, sudo bool) (bool, error) +} + +func (s *Session) runBypassLoop(broker BypassEvaluator) { + for { + // Get a fresh connection for each request (blocking reads don't multiplex well) + conn := s.DialToolServer() + if conn == nil { + select { + case <-s.Ctx.Done(): + return + case <-time.After(1 * time.Second): + continue + } + } + + req, err := conn.ReadBypassPending() + conn.Close() + if err != nil { + select { + case <-s.Ctx.Done(): + return + default: + s.log.Debug("bypass read error: %v", err) + time.Sleep(100 * time.Millisecond) + continue + } + } + + // Evaluate the request + approved, evalErr := broker.EvaluateRequest(s.ID, req.Cmd, req.Cwd, req.Env, req.Sudo) + + // Send resolution back to toolsrv + resolveConn := s.DialToolServer() + if resolveConn == nil { + s.log.Warn("bypass: can't dial to resolve request %s", req.ID) + continue + } + errMsg := "" + if evalErr != nil { + errMsg = evalErr.Error() + } + if err := resolveConn.ResolveBypass(req.ID, approved, errMsg); err != nil { + s.log.Warn("bypass: resolve failed for %s: %v", req.ID, err) + } + resolveConn.Close() + } +} + // IsPaused returns true if the session is paused. func (s *Session) IsPaused() bool { s.mu.RLock() diff --git a/cmd/toolsrv/internal/bypass/bypass.go b/cmd/toolsrv/internal/bypass/bypass.go new file mode 100644 index 0000000..7dfb62e --- /dev/null +++ b/cmd/toolsrv/internal/bypass/bypass.go @@ -0,0 +1,96 @@ +// Package bypass handles bypass request submission and resolution for toolsrv. +// Bypass requests are submitted by sandboxed execution, exposed via 9P for +// external approval (olliesrv), and resolved when the approver responds. +package bypass + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "sync" +) + +// Request represents a bypass request. +type Request struct { + ID string `json:"id"` + Cmd string `json:"cmd"` + Cwd string `json:"cwd"` + Env map[string]string `json:"env,omitempty"` + Sudo bool `json:"sudo,omitempty"` + + // Resolution state (not serialized) + done chan struct{} + approved bool + err error +} + +var ( + pending = make(chan *Request, 16) + mu sync.Mutex + requests = make(map[string]*Request) +) + +// Submit submits a bypass request and blocks until resolved. +// Returns (approved, error). If denied, approved is false and error is nil. +// If there's an error (e.g., timeout, connection lost), error is non-nil. +func Submit(cmd, cwd string, env map[string]string, sudo bool) (bool, error) { + req := &Request{ + ID: nextID(), + Cmd: cmd, + Cwd: cwd, + Env: env, + Sudo: sudo, + done: make(chan struct{}), + } + + mu.Lock() + requests[req.ID] = req + mu.Unlock() + + pending <- req + <-req.done + + mu.Lock() + delete(requests, req.ID) + mu.Unlock() + + return req.approved, req.err +} + +// NextPending blocks until a request is available and returns it. +// Used by the 9P pending file to expose requests to the approver. +func NextPending() *Request { + return <-pending +} + +// Resolve completes a pending request with the given decision. +// Returns false if the request ID is not found (already resolved or invalid). +func Resolve(id string, approved bool, errMsg string) bool { + mu.Lock() + req, ok := requests[id] + mu.Unlock() + + if !ok { + return false + } + + req.approved = approved + if errMsg != "" { + req.err = errors.New(errMsg) + } + close(req.done) + return true +} + +// PendingCount returns the number of pending requests (for diagnostics). +func PendingCount() int { + mu.Lock() + defer mu.Unlock() + return len(requests) +} + +func nextID() string { + b := make([]byte, 8) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/cmd/toolsrv/internal/exec/exec.go b/cmd/toolsrv/internal/exec/exec.go index 9900294..7e4295a 100644 --- a/cmd/toolsrv/internal/exec/exec.go +++ b/cmd/toolsrv/internal/exec/exec.go @@ -4,18 +4,17 @@ package exec import ( "bytes" "context" - "encoding/binary" "encoding/json" "fmt" "io" - "net" "os" - "os/exec" + osExec "os/exec" "path/filepath" "strings" "syscall" "time" + "ollie/cmd/toolsrv/internal/bypass" "ollie/cmd/toolsrv/internal/sandbox" "ollie/paths" "ollie/toolsrv" @@ -165,15 +164,15 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE } getenv := func(key string) string { return envMap[key] } - var cmd *exec.Cmd + var cmd *osExec.Cmd if yolo { - cmd = exec.CommandContext(ctx, interpreter[0], interpreter[1:]...) + cmd = osExec.CommandContext(ctx, interpreter[0], interpreter[1:]...) } else { wrapped, wrapErr := sandbox.WrapCommand(sandboxCfg, interpreter, cwd, getenv) if wrapErr != nil { return "", wrapErr } - cmd = exec.CommandContext(ctx, wrapped[0], wrapped[1:]...) + cmd = osExec.CommandContext(ctx, wrapped[0], wrapped[1:]...) } cmd.Dir = cwd @@ -239,20 +238,9 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE return string(output), nil } -// executeBypassDirect runs a command via the bypass broker. +// executeBypassDirect requests bypass approval and executes the command directly. +// The approval comes from olliesrv via the 9P bypass/pending and bypass/resolve files. func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[string]string, timeout int, sudo bool) (string, error) { - xdg := os.Getenv("XDG_RUNTIME_DIR") - if xdg == "" { - return "", fmt.Errorf("bypass not available: no XDG_RUNTIME_DIR") - } - sockPath := filepath.Join(xdg, "ollie", "bypass.sock") - - if timeout > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second) - defer cancel() - } - // Build environment map envMap := make(map[string]string) for _, kv := range os.Environ() { @@ -264,78 +252,77 @@ func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[stri envMap[k] = v } - // Connect and send request - conn, err := net.DialTimeout("unix", sockPath, 5*time.Second) + // Submit bypass request and wait for approval + approved, err := bypass.Submit(cmd, cwd, envMap, sudo) if err != nil { - return "", fmt.Errorf("bypass not available: %w", err) + return "", fmt.Errorf("bypass request failed: %w", err) } - defer conn.Close() - - reqJSON, _ := json.Marshal(struct { - Cmd string `json:"cmd"` - Cwd string `json:"cwd"` - Env map[string]string `json:"env"` - Session string `json:"session"` - Sudo bool `json:"sudo,omitempty"` - }{Cmd: cmd, Cwd: cwd, Env: envMap, Session: os.Getenv("OLLIE_SESSION_ID"), Sudo: sudo}) - reqJSON = append(reqJSON, '\n') - - if _, err := conn.Write(reqJSON); err != nil { - return "", fmt.Errorf("bypass write failed: %w", err) + if !approved { + return "", fmt.Errorf("bypass denied") } - // Read response frames - var outputBuf bytes.Buffer - exitCode := readBypassFrames(ctx, conn, &outputBuf, StreamFunc(ctx)) + // Approved - execute directly without sandbox + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + } - output := outputBuf.String() - if exitCode != 0 { - return output, fmt.Errorf("bypass execution failed (exit %d)", exitCode) + return executeDirectUnsandboxed(ctx, cmd, cwd, envMap, sudo) +} + +// executeDirectUnsandboxed runs a command without any sandbox. +func executeDirectUnsandboxed(ctx context.Context, cmd, cwd string, env map[string]string, sudo bool) (string, error) { + var execCmd *osExec.Cmd + if sudo { + execCmd = osExec.CommandContext(ctx, "sudo", "-E", "bash", "-c", cmd) + } else { + execCmd = osExec.CommandContext(ctx, "bash", "-c", cmd) + } + execCmd.Dir = cwd + if len(env) > 0 { + execCmd.Env = make([]string, 0, len(env)) + for k, v := range env { + execCmd.Env = append(execCmd.Env, k+"="+v) + } + } + + var stdout, stderr bytes.Buffer + execCmd.Stdout = &stdout + execCmd.Stderr = &stderr + + // Stream output if context has a stream function + if stream := StreamFunc(ctx); stream != nil { + execCmd.Stdout = io.MultiWriter(&stdout, &streamWriter{stream}) + execCmd.Stderr = io.MultiWriter(&stderr, &streamWriter{stream}) + } + + err := execCmd.Run() + output := stdout.String() + if stderr.Len() > 0 { + if output != "" { + output += "\n" + } + output += stderr.String() + } + + if err != nil { + if exitErr, ok := err.(*osExec.ExitError); ok { + return output, fmt.Errorf("exit %d", exitErr.ExitCode()) + } + return output, err } return output, nil } -// readBypassFrames reads framed output from bypass broker. -func readBypassFrames(ctx context.Context, conn net.Conn, w *bytes.Buffer, stream func(string)) int { - header := make([]byte, 5) - for { - conn.SetReadDeadline(time.Now().Add(1 * time.Second)) - _, err := io.ReadFull(conn, header) - if err != nil { - if ne, ok := err.(net.Error); ok && ne.Timeout() { - select { - case <-ctx.Done(): - return -1 - default: - continue - } - } - return -1 - } +// streamWriter adapts a stream function to io.Writer. +type streamWriter struct { + fn func(string) +} - frameType := header[0] - length := binary.BigEndian.Uint32(header[1:5]) - - payload := make([]byte, length) - if length > 0 { - conn.SetReadDeadline(time.Now().Add(30 * time.Second)) - if _, err := io.ReadFull(conn, payload); err != nil { - return -1 - } - } - - switch frameType { - case 'd': - w.Write(payload) - if stream != nil { - stream(string(payload)) - } - case 'x': - var code int - fmt.Sscanf(string(payload), "%d", &code) - return code - } - } +func (w *streamWriter) Write(p []byte) (int, error) { + w.fn(string(p)) + return len(p), nil } // Plan9Namespace computes the Plan 9 namespace directory. diff --git a/cmd/toolsrv/internal/server/server.go b/cmd/toolsrv/internal/server/server.go index eaa7db5..910cbec 100644 --- a/cmd/toolsrv/internal/server/server.go +++ b/cmd/toolsrv/internal/server/server.go @@ -5,6 +5,7 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "fmt" "os" "runtime" @@ -12,6 +13,7 @@ import ( "strings" "sync" + "ollie/cmd/toolsrv/internal/bypass" "ollie/cmd/toolsrv/internal/registry" "ollie/virtfs" ) @@ -129,6 +131,36 @@ func Spec(srv *Server) virtfs.FsNodeDecl { return []byte(srv.HostInfo()), nil }), ), + virtfs.DirNode("bypass", + virtfs.FileNode("pending", 0444, + virtfs.Doc("Blocks until bypass request; returns JSON {id, cmd, cwd, env, sudo}"), + virtfs.BlockOnce(func(_ context.Context, _ string) ([]byte, string, error) { + req := bypass.NextPending() + data, err := json.Marshal(req) + if err != nil { + return nil, "", err + } + return append(data, '\n'), "", nil + }), + ), + virtfs.FileNode("resolve", 0222, + virtfs.Doc("Resolve bypass request: write JSON {id, approved} or {id, error}"), + virtfs.Write(func(data []byte) error { + var msg struct { + ID string `json:"id"` + Approved bool `json:"approved"` + Error string `json:"error"` + } + if err := json.Unmarshal(data, &msg); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + if !bypass.Resolve(msg.ID, msg.Approved, msg.Error) { + return fmt.Errorf("unknown request ID: %s", msg.ID) + } + return nil + }), + ), + ), virtfs.DirNode("proc", virtfs.FileNode("new", 0666, virtfs.Doc("Execute tool: write token + tool + args, read result (blocking)"), diff --git a/toolsrv/client9p.go b/toolsrv/client9p.go index df5a1e9..8e8e44a 100644 --- a/toolsrv/client9p.go +++ b/toolsrv/client9p.go @@ -523,3 +523,69 @@ func escapeValue(s string) string { } return string(result) } + +// BypassRequest represents a bypass request from toolsrv. +type BypassRequest struct { + ID string `json:"id"` + Cmd string `json:"cmd"` + Cwd string `json:"cwd"` + Env map[string]string `json:"env,omitempty"` + Sudo bool `json:"sudo,omitempty"` +} + +// ReadBypassPending blocks until a bypass request is available. +// This should be called in a loop by the approval handler. +func (c *Conn) ReadBypassPending() (*BypassRequest, error) { + fid, err := c.fsys.Open("bypass/pending", plan9.OREAD) + if err != nil { + return nil, fmt.Errorf("open bypass/pending: %w", err) + } + defer fid.Close() + + // Blocking read - will return when a request is available + var result []byte + buf := make([]byte, 8192) + for { + n, err := fid.Read(buf) + if n > 0 { + result = append(result, buf[:n]...) + } + if err == io.EOF || n == 0 { + break + } + if err != nil { + return nil, fmt.Errorf("read bypass/pending: %w", err) + } + } + + var req BypassRequest + if err := json.Unmarshal(result, &req); err != nil { + return nil, fmt.Errorf("parse bypass request: %w", err) + } + return &req, nil +} + +// ResolveBypass sends the approval/denial decision for a bypass request. +func (c *Conn) ResolveBypass(id string, approved bool, errMsg string) error { + fid, err := c.fsys.Open("bypass/resolve", plan9.OWRITE) + if err != nil { + return fmt.Errorf("open bypass/resolve: %w", err) + } + defer fid.Close() + + msg := struct { + ID string `json:"id"` + Approved bool `json:"approved"` + Error string `json:"error,omitempty"` + }{ID: id, Approved: approved, Error: errMsg} + + data, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshal resolve: %w", err) + } + + if _, err := fid.Write(data); err != nil { + return fmt.Errorf("write bypass/resolve: %w", err) + } + return nil +}