elevate: broker credential support for privileged tool execution

This commit is contained in:
Ollie Agent 2026-07-30 23:37:57 +02:00
parent 5955562c1d
commit 3d65d787a2
5 changed files with 139 additions and 10 deletions

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings" "strings"
@ -239,11 +240,31 @@ func runServer(sockPath string) {
notifyFn := func(req *elevate.Request) { notifyFn := func(req *elevate.Request) {
notifyElevation(req) 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 var err error
elevateBroker, err = elevate.NewBroker(elevate.BrokerConfig{ elevateBroker, err = elevate.NewBroker(elevate.BrokerConfig{
SocketPath: elevateSocket, SocketPath: elevateSocket,
PolicyPath: policyPath, PolicyPath: policyPath,
Notify: notifyFn, Notify: notifyFn,
Credential: credentialPrompt,
Logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) }, Logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) },
SessionValid: func(id string) bool { return fs.Lookup(sessionTree, id) != nil }, SessionValid: func(id string) bool { return fs.Lookup(sessionTree, id) != nil },
}) })

View File

@ -23,14 +23,11 @@ const (
RefillInterval = 10 * time.Second // one slot recovers every 10s (30s to full) 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. // Broker manages elevation requests.
type Broker struct { type Broker struct {
policy *PolicyStore policy *PolicyStore
notify NotifyFunc notify NotifyFunc
credential CredentialFunc
logf func(string, ...any) logf func(string, ...any)
sessionValid func(string) bool sessionValid func(string) bool
@ -49,10 +46,18 @@ type BrokerConfig struct {
SocketPath string SocketPath string
PolicyPath string // disk path for global policy YAML PolicyPath string // disk path for global policy YAML
Notify NotifyFunc Notify NotifyFunc
Credential CredentialFunc // prompts user for sudo password; nil = sudo unsupported
Logf func(string, ...any) Logf func(string, ...any)
SessionValid func(sessionID string) bool // returns true if session exists 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. // NewBroker creates and starts the elevation broker.
func NewBroker(cfg BrokerConfig) (*Broker, error) { func NewBroker(cfg BrokerConfig) (*Broker, error) {
if cfg.Logf == nil { if cfg.Logf == nil {
@ -79,6 +84,7 @@ func NewBroker(cfg BrokerConfig) (*Broker, error) {
b := &Broker{ b := &Broker{
policy: NewPolicyStore(cfg.PolicyPath), policy: NewPolicyStore(cfg.PolicyPath),
notify: cfg.Notify, notify: cfg.Notify,
credential: cfg.Credential,
logf: cfg.Logf, logf: cfg.Logf,
sessionValid: cfg.SessionValid, sessionValid: cfg.SessionValid,
pending: make(map[string]*Request), pending: make(map[string]*Request),
@ -262,6 +268,7 @@ func (b *Broker) handleConn(conn net.Conn) {
Cwd string `json:"cwd"` Cwd string `json:"cwd"`
Env map[string]string `json:"env"` Env map[string]string `json:"env"`
Session string `json:"session"` Session string `json:"session"`
Sudo bool `json:"sudo"`
} }
if err := json.Unmarshal(buf, &msg); err != nil { if err := json.Unmarshal(buf, &msg); err != nil {
sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: bad request: %v\n", err))) 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) effective := b.SessionPolicy(sessionID)
if effective.Matches(msg.Cmd, "") { if effective.Matches(msg.Cmd, "") {
b.logf("elevate: auto-approved cmd=%q session=%s", msg.Cmd, sessionID) 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 return
} }
@ -313,6 +324,7 @@ func (b *Broker) handleConn(conn net.Conn) {
Cwd: msg.Cwd, Cwd: msg.Cwd,
Env: msg.Env, Env: msg.Env,
SessionID: sessionID, SessionID: sessionID,
Sudo: msg.Sudo,
CreatedAt: time.Now(), CreatedAt: time.Now(),
resolved: make(chan Resolution, 1), resolved: make(chan Resolution, 1),
conn: conn, conn: conn,
@ -344,7 +356,11 @@ func (b *Broker) handleConn(conn net.Conn) {
switch res { switch res {
case ResolveApprove, ResolvePersist: case ResolveApprove, ResolvePersist:
b.logf("elevate: approved id=%s cmd=%q", req.ID, msg.Cmd) 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: default:
b.recordDenial(sessionID) b.recordDenial(sessionID)
b.logf("elevate: denied id=%s cmd=%q reason=%s", req.ID, msg.Cmd, res) 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' '<password>' | sudo -S bash -c '<cmd>'
// 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) { func (b *Broker) executeAndStream(conn net.Conn, cmd, cwd string, env map[string]string) {
c := exec.Command("bash", "-c", cmd) c := exec.Command("bash", "-c", cmd)
c.Dir = cwd c.Dir = cwd

View File

@ -40,6 +40,7 @@ type Request struct {
Cwd string Cwd string
Env map[string]string Env map[string]string
SessionID string // ollie session that made the request (from SO_PEERCRED mapping) SessionID string // ollie session that made the request (from SO_PEERCRED mapping)
Sudo bool // requires sudo credential after approval
CreatedAt time.Time CreatedAt time.Time
// Resolution channel — exactly one value sent when resolved. // Resolution channel — exactly one value sent when resolved.

View File

@ -338,9 +338,8 @@ func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.Ra
e.wdMu.RLock() e.wdMu.RLock()
workDir := e.cwd workDir := e.cwd
e.wdMu.RUnlock() e.wdMu.RUnlock()
// sudo -n: non-interactive (NOPASSWD). Full credential broker TBD. sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", code, stdinData)
sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | sudo -n %s\n%s\nOLLIE_EOF", code, stdinData) result, err = e.executeElevatedSudo(ctx, sudoCode, workDir, 30)
result, err = e.executeElevated(ctx, sudoCode, workDir, 30)
} else if elevated { } else if elevated {
e.wdMu.RLock() e.wdMu.RLock()
workDir := e.cwd workDir := e.cwd

View File

@ -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, // Connects to the broker socket, sends the request with the current env,
// and streams the framed response back. // and streams the framed response back.
func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) { 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") sockPath := os.Getenv("OLLIE_ELEVATE_SOCKET")
if sockPath == "" { if sockPath == "" {
xdg := os.Getenv("XDG_RUNTIME_DIR") 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"` Cwd string `json:"cwd"`
Env map[string]string `json:"env"` Env map[string]string `json:"env"`
Session string `json:"session,omitempty"` 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') reqJSON = append(reqJSON, '\n')
if _, err := conn.Write(reqJSON); err != nil { if _, err := conn.Write(reqJSON); err != nil {
conn.Close() conn.Close()