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()
}
// 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

View File

@ -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.

View File

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

View File

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

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 (
"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.

View File

@ -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)"),

View File

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