From 3d65d787a2b339f3462fac00a977c50a4f1d8b10 Mon Sep 17 00:00:00 2001 From: Ollie Agent Date: Thu, 30 Jul 2026 23:37:57 +0200 Subject: [PATCH] elevate: broker credential support for privileged tool execution --- cmd/olliesrv/main.go | 21 ++++++++ elevate/broker.go | 111 ++++++++++++++++++++++++++++++++++++++++--- elevate/request.go | 1 + toolsrv/server.go | 5 +- toolsrv/shell.go | 11 ++++- 5 files changed, 139 insertions(+), 10 deletions(-) diff --git a/cmd/olliesrv/main.go b/cmd/olliesrv/main.go index a9e7dd1..f41f54d 100644 --- a/cmd/olliesrv/main.go +++ b/cmd/olliesrv/main.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "os" + "os/exec" "os/signal" "path/filepath" "strings" @@ -239,11 +240,31 @@ func runServer(sockPath string) { notifyFn := func(req *elevate.Request) { notifyElevation(req) } + credentialPrompt := func(req *elevate.Request) (string, error) { + // Try kdialog (KDE), then zenity (GTK), then terminal fallback + title := fmt.Sprintf("sudo password for: %s", req.Cmd) + if path, err := exec.LookPath("kdialog"); err == nil { + out, err := exec.Command(path, "--password", title).Output() + if err != nil { + return "", fmt.Errorf("user cancelled") + } + return strings.TrimRight(string(out), "\n"), nil + } + if path, err := exec.LookPath("zenity"); err == nil { + out, err := exec.Command(path, "--password", "--title", title).Output() + if err != nil { + return "", fmt.Errorf("user cancelled") + } + return strings.TrimRight(string(out), "\n"), nil + } + return "", fmt.Errorf("no credential provider available (install kdialog or zenity)") + } var err error elevateBroker, err = elevate.NewBroker(elevate.BrokerConfig{ SocketPath: elevateSocket, PolicyPath: policyPath, Notify: notifyFn, + Credential: credentialPrompt, Logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) }, SessionValid: func(id string) bool { return fs.Lookup(sessionTree, id) != nil }, }) diff --git a/elevate/broker.go b/elevate/broker.go index 64eb4eb..3268e1d 100644 --- a/elevate/broker.go +++ b/elevate/broker.go @@ -23,14 +23,11 @@ const ( RefillInterval = 10 * time.Second // one slot recovers every 10s (30s to full) ) -// NotifyFunc is called when a new request needs human attention. -// Implementations should show a notification or log the request. -type NotifyFunc func(req *Request) - // Broker manages elevation requests. type Broker struct { policy *PolicyStore notify NotifyFunc + credential CredentialFunc logf func(string, ...any) sessionValid func(string) bool @@ -49,10 +46,18 @@ type BrokerConfig struct { SocketPath string PolicyPath string // disk path for global policy YAML Notify NotifyFunc + Credential CredentialFunc // prompts user for sudo password; nil = sudo unsupported Logf func(string, ...any) SessionValid func(sessionID string) bool // returns true if session exists } +// NotifyFunc is called when a new elevation request needs user attention. +type NotifyFunc func(*Request) + +// CredentialFunc prompts the user for a sudo password. Returns the password +// or an error if the user cancels. Called only after elevation is approved. +type CredentialFunc func(req *Request) (string, error) + // NewBroker creates and starts the elevation broker. func NewBroker(cfg BrokerConfig) (*Broker, error) { if cfg.Logf == nil { @@ -79,6 +84,7 @@ func NewBroker(cfg BrokerConfig) (*Broker, error) { b := &Broker{ policy: NewPolicyStore(cfg.PolicyPath), notify: cfg.Notify, + credential: cfg.Credential, logf: cfg.Logf, sessionValid: cfg.SessionValid, pending: make(map[string]*Request), @@ -262,6 +268,7 @@ func (b *Broker) handleConn(conn net.Conn) { Cwd string `json:"cwd"` Env map[string]string `json:"env"` Session string `json:"session"` + Sudo bool `json:"sudo"` } if err := json.Unmarshal(buf, &msg); err != nil { sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: bad request: %v\n", err))) @@ -302,7 +309,11 @@ func (b *Broker) handleConn(conn net.Conn) { effective := b.SessionPolicy(sessionID) if effective.Matches(msg.Cmd, "") { b.logf("elevate: auto-approved cmd=%q session=%s", msg.Cmd, sessionID) - b.executeAndStream(conn, msg.Cmd, msg.Cwd, msg.Env) + if msg.Sudo { + b.executeWithSudo(conn, msg.Cmd, msg.Cwd, msg.Env, &Request{Cmd: msg.Cmd, SessionID: sessionID}) + } else { + b.executeAndStream(conn, msg.Cmd, msg.Cwd, msg.Env) + } return } @@ -313,6 +324,7 @@ func (b *Broker) handleConn(conn net.Conn) { Cwd: msg.Cwd, Env: msg.Env, SessionID: sessionID, + Sudo: msg.Sudo, CreatedAt: time.Now(), resolved: make(chan Resolution, 1), conn: conn, @@ -344,7 +356,11 @@ func (b *Broker) handleConn(conn net.Conn) { switch res { case ResolveApprove, ResolvePersist: b.logf("elevate: approved id=%s cmd=%q", req.ID, msg.Cmd) - b.executeAndStream(conn, msg.Cmd, msg.Cwd, msg.Env) + if req.Sudo { + b.executeWithSudo(conn, msg.Cmd, msg.Cwd, msg.Env, req) + } else { + b.executeAndStream(conn, msg.Cmd, msg.Cwd, msg.Env) + } default: b.recordDenial(sessionID) b.logf("elevate: denied id=%s cmd=%q reason=%s", req.ID, msg.Cmd, res) @@ -353,6 +369,89 @@ func (b *Broker) handleConn(conn net.Conn) { } } +func (b *Broker) executeWithSudo(conn net.Conn, cmd, cwd string, env map[string]string, req *Request) { + if b.credential == nil { + sendFrame(conn, FrameData, []byte("elevate-broker: sudo requested but no credential provider configured\n")) + sendFrame(conn, FrameExit, []byte("1")) + return + } + + password, err := b.credential(req) + if err != nil { + b.logf("elevate: credential denied for cmd=%q: %v", cmd, err) + sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate-broker: credential denied: %v\n", err))) + sendFrame(conn, FrameExit, []byte("1")) + return + } + + // Build: printf '%s\n' '' | sudo -S bash -c '' + // Password is first line on stdin; sudo -S reads it then runs the command. + c := exec.Command("sudo", "-S", "bash", "-c", cmd) + c.Dir = cwd + if len(env) > 0 { + c.Env = make([]string, 0, len(env)) + for k, v := range env { + c.Env = append(c.Env, k+"="+v) + } + } + c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + // Pipe password to stdin + stdinPipe, err := c.StdinPipe() + if err != nil { + sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: stdin pipe: %v\n", err))) + sendFrame(conn, FrameExit, []byte("1")) + return + } + + // Pipe stdout+stderr to the connection via frames + pr, pw, err := os.Pipe() + if err != nil { + sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: pipe: %v\n", err))) + sendFrame(conn, FrameExit, []byte("1")) + return + } + c.Stdout = pw + c.Stderr = pw + + if err := c.Start(); err != nil { + pw.Close() + pr.Close() + sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: exec sudo: %v\n", err))) + sendFrame(conn, FrameExit, []byte("1")) + return + } + + // Send password to sudo -S + fmt.Fprintf(stdinPipe, "%s\n", password) + stdinPipe.Close() + pw.Close() + + // Stream output + buf := make([]byte, 4096) + for { + n, err := pr.Read(buf) + if n > 0 { + sendFrame(conn, FrameData, buf[:n]) + } + if err != nil { + break + } + } + pr.Close() + + err = c.Wait() + exitCode := 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + exitCode = ee.ExitCode() + } else { + exitCode = 1 + } + } + sendFrame(conn, FrameExit, []byte(fmt.Sprintf("%d", exitCode))) +} + func (b *Broker) executeAndStream(conn net.Conn, cmd, cwd string, env map[string]string) { c := exec.Command("bash", "-c", cmd) c.Dir = cwd diff --git a/elevate/request.go b/elevate/request.go index a731016..fcc3faa 100644 --- a/elevate/request.go +++ b/elevate/request.go @@ -40,6 +40,7 @@ type Request struct { Cwd string Env map[string]string SessionID string // ollie session that made the request (from SO_PEERCRED mapping) + Sudo bool // requires sudo credential after approval CreatedAt time.Time // Resolution channel — exactly one value sent when resolved. diff --git a/toolsrv/server.go b/toolsrv/server.go index 24f6830..f294e13 100644 --- a/toolsrv/server.go +++ b/toolsrv/server.go @@ -338,9 +338,8 @@ func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.Ra e.wdMu.RLock() workDir := e.cwd e.wdMu.RUnlock() - // sudo -n: non-interactive (NOPASSWD). Full credential broker TBD. - sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | sudo -n %s\n%s\nOLLIE_EOF", code, stdinData) - result, err = e.executeElevated(ctx, sudoCode, workDir, 30) + sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", code, stdinData) + result, err = e.executeElevatedSudo(ctx, sudoCode, workDir, 30) } else if elevated { e.wdMu.RLock() workDir := e.cwd diff --git a/toolsrv/shell.go b/toolsrv/shell.go index 41d8cf6..f2de2e1 100644 --- a/toolsrv/shell.go +++ b/toolsrv/shell.go @@ -117,6 +117,14 @@ func (lw *limitedWriter) Write(p []byte) (n int, err error) { // Connects to the broker socket, sends the request with the current env, // and streams the framed response back. func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) { + return e.executeElevatedOpts(ctx, cmd, dir, timeout, false, doDetach...) +} + +func (e *Server) executeElevatedSudo(ctx context.Context, cmd, dir string, timeout int) (string, error) { + return e.executeElevatedOpts(ctx, cmd, dir, timeout, true) +} + +func (e *Server) executeElevatedOpts(ctx context.Context, cmd, dir string, timeout int, sudo bool, doDetach ...bool) (string, error) { sockPath := os.Getenv("OLLIE_ELEVATE_SOCKET") if sockPath == "" { xdg := os.Getenv("XDG_RUNTIME_DIR") @@ -159,7 +167,8 @@ func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout i Cwd string `json:"cwd"` Env map[string]string `json:"env"` Session string `json:"session,omitempty"` - }{Cmd: cmd, Cwd: dir, Env: envMap, Session: e.sessionID}) + Sudo bool `json:"sudo,omitempty"` + }{Cmd: cmd, Cwd: dir, Env: envMap, Session: e.sessionID, Sudo: sudo}) reqJSON = append(reqJSON, '\n') if _, err := conn.Write(reqJSON); err != nil { conn.Close()