bypass: route approval through 9P instead of Unix socket

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  │
  └─────────────────┘
This commit is contained in:
Levi Neely 2026-08-12 08:48:32 +02:00
parent 1814f82928
commit 8bb5c098cc
8 changed files with 417 additions and 99 deletions

View File

@ -212,6 +212,76 @@ func (b *Broker) recordDenial(sessionID string) {
b.limiterFor(sessionID).Allow() 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. // GlobalPolicy returns the policy store for 9P access.
func (b *Broker) GlobalPolicy() *PolicyStore { func (b *Broker) GlobalPolicy() *PolicyStore {
return b.policy return b.policy

View File

@ -28,12 +28,13 @@ type Config struct {
func NewRoot(cfg Config) *Tree { func NewRoot(cfg Config) *Tree {
// Initialize session package // Initialize session package
session.Init(session.InitConfig{ session.Init(session.InitConfig{
Ctx: cfg.Ctx, Ctx: cfg.Ctx,
Log: cfg.Log, Log: cfg.Log,
Sink: cfg.Sink, Sink: cfg.Sink,
AgentsDir: cfg.AgentsDir, AgentsDir: cfg.AgentsDir,
SessionsDir: cfg.SessionsDir, SessionsDir: cfg.SessionsDir,
Yolo: cfg.Yolo, Yolo: cfg.Yolo,
BypassBroker: cfg.BypassBroker,
}) })
// Build the tree from the spec. // Build the tree from the spec.

View File

@ -29,22 +29,24 @@ var (
sessions = make(map[string]*Session) sessions = make(map[string]*Session)
// Package config, set via Init. // Package config, set via Init.
serverCtx context.Context serverCtx context.Context
pkgLog *olog.Logger pkgLog *olog.Logger
pkgSink *olog.Sink pkgSink *olog.Sink
pkgAgentsDir string pkgAgentsDir string
pkgSessionsDir string pkgSessionsDir string
pkgYolo bool pkgYolo bool
pkgBypassBroker BypassEvaluator
) )
// InitConfig configures the session package. // InitConfig configures the session package.
type InitConfig struct { type InitConfig struct {
Ctx context.Context Ctx context.Context
Log *olog.Logger Log *olog.Logger
Sink *olog.Sink Sink *olog.Sink
AgentsDir string AgentsDir string
SessionsDir string SessionsDir string
Yolo bool Yolo bool
BypassBroker BypassEvaluator // may be nil if bypass is disabled
} }
// Init initializes the session package with the given configuration. // Init initializes the session package with the given configuration.
@ -55,6 +57,7 @@ func Init(cfg InitConfig) {
pkgAgentsDir = cfg.AgentsDir pkgAgentsDir = cfg.AgentsDir
pkgSessionsDir = cfg.SessionsDir pkgSessionsDir = cfg.SessionsDir
pkgYolo = cfg.Yolo pkgYolo = cfg.Yolo
pkgBypassBroker = cfg.BypassBroker
} }
// Sessions returns a snapshot of all sessions. // Sessions returns a snapshot of all sessions.
@ -317,6 +320,11 @@ func CreateEmpty(name, remote string) (*Session, error) {
sess.Keeper = infra.Keeper sess.Keeper = infra.Keeper
sess.SetToolsConn(infra.ToolsConn) sess.SetToolsConn(infra.ToolsConn)
// Start bypass approval loop (if broker is configured)
if pkgBypassBroker != nil {
sess.StartBypassLoop(pkgBypassBroker)
}
mu.Lock() mu.Lock()
sessions[name] = sess sessions[name] = sess
mu.Unlock() mu.Unlock()

View File

@ -112,6 +112,64 @@ func (s *Session) SetToolsConn(conn *toolsrv.Conn) {
s.toolsConn = 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. // IsPaused returns true if the session is paused.
func (s *Session) IsPaused() bool { func (s *Session) IsPaused() bool {
s.mu.RLock() s.mu.RLock()

View File

@ -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)
}

View File

@ -4,18 +4,17 @@ package exec
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/binary"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net"
"os" "os"
"os/exec" osExec "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall" "syscall"
"time" "time"
"ollie/cmd/toolsrv/internal/bypass"
"ollie/cmd/toolsrv/internal/sandbox" "ollie/cmd/toolsrv/internal/sandbox"
"ollie/paths" "ollie/paths"
"ollie/toolsrv" "ollie/toolsrv"
@ -165,15 +164,15 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE
} }
getenv := func(key string) string { return envMap[key] } getenv := func(key string) string { return envMap[key] }
var cmd *exec.Cmd var cmd *osExec.Cmd
if yolo { if yolo {
cmd = exec.CommandContext(ctx, interpreter[0], interpreter[1:]...) cmd = osExec.CommandContext(ctx, interpreter[0], interpreter[1:]...)
} else { } else {
wrapped, wrapErr := sandbox.WrapCommand(sandboxCfg, interpreter, cwd, getenv) wrapped, wrapErr := sandbox.WrapCommand(sandboxCfg, interpreter, cwd, getenv)
if wrapErr != nil { if wrapErr != nil {
return "", wrapErr return "", wrapErr
} }
cmd = exec.CommandContext(ctx, wrapped[0], wrapped[1:]...) cmd = osExec.CommandContext(ctx, wrapped[0], wrapped[1:]...)
} }
cmd.Dir = cwd cmd.Dir = cwd
@ -239,20 +238,9 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE
return string(output), nil 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) { 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 // Build environment map
envMap := make(map[string]string) envMap := make(map[string]string)
for _, kv := range os.Environ() { for _, kv := range os.Environ() {
@ -264,78 +252,77 @@ func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[stri
envMap[k] = v envMap[k] = v
} }
// Connect and send request // Submit bypass request and wait for approval
conn, err := net.DialTimeout("unix", sockPath, 5*time.Second) approved, err := bypass.Submit(cmd, cwd, envMap, sudo)
if err != nil { if err != nil {
return "", fmt.Errorf("bypass not available: %w", err) return "", fmt.Errorf("bypass request failed: %w", err)
} }
defer conn.Close() if !approved {
return "", fmt.Errorf("bypass denied")
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)
} }
// Read response frames // Approved - execute directly without sandbox
var outputBuf bytes.Buffer if timeout > 0 {
exitCode := readBypassFrames(ctx, conn, &outputBuf, StreamFunc(ctx)) var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
}
output := outputBuf.String() return executeDirectUnsandboxed(ctx, cmd, cwd, envMap, sudo)
if exitCode != 0 { }
return output, fmt.Errorf("bypass execution failed (exit %d)", exitCode)
// 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 return output, nil
} }
// readBypassFrames reads framed output from bypass broker. // streamWriter adapts a stream function to io.Writer.
func readBypassFrames(ctx context.Context, conn net.Conn, w *bytes.Buffer, stream func(string)) int { type streamWriter struct {
header := make([]byte, 5) fn func(string)
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
}
frameType := header[0] func (w *streamWriter) Write(p []byte) (int, error) {
length := binary.BigEndian.Uint32(header[1:5]) w.fn(string(p))
return len(p), nil
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
}
}
} }
// Plan9Namespace computes the Plan 9 namespace directory. // Plan9Namespace computes the Plan 9 namespace directory.

View File

@ -5,6 +5,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"os" "os"
"runtime" "runtime"
@ -12,6 +13,7 @@ import (
"strings" "strings"
"sync" "sync"
"ollie/cmd/toolsrv/internal/bypass"
"ollie/cmd/toolsrv/internal/registry" "ollie/cmd/toolsrv/internal/registry"
"ollie/virtfs" "ollie/virtfs"
) )
@ -129,6 +131,36 @@ func Spec(srv *Server) virtfs.FsNodeDecl {
return []byte(srv.HostInfo()), nil 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.DirNode("proc",
virtfs.FileNode("new", 0666, virtfs.FileNode("new", 0666,
virtfs.Doc("Execute tool: write token + tool + args, read result (blocking)"), virtfs.Doc("Execute tool: write token + tool + args, read result (blocking)"),

View File

@ -523,3 +523,69 @@ func escapeValue(s string) string {
} }
return string(result) 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
}