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:
parent
1814f82928
commit
8bb5c098cc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue