olliesrv: expose elevate/ and s/{id}/elevate via 9P

- /elevate/policy: read/write global policy (YAML)
- /elevate/pending/: list pending requests
- /elevate/pending/<id>: read summary, write approve/deny/persist
- /s/{id}/elevate: read/write session policy (includes global)
This commit is contained in:
Levi Neely 2026-07-27 11:03:20 +02:00
parent 324abd025b
commit 238d8214a3
4 changed files with 242 additions and 12 deletions

View File

@ -17,6 +17,7 @@ import "os"
const (
PathRoot = "/"
PathSessions = "/s/"
PathElevate = "/elevate/"
PathSessionDir = "s/{id}"
PathTools = "t/"
PathSessionFile = "s/{id}/{file}"
@ -43,6 +44,14 @@ var Perms = map[string]Perm{
"help": 0444,
},
},
PathElevate: {
DirMode: 0755,
FileMode: 0666,
Files: map[string]os.FileMode{
"policy": 0666,
"pending": 0755,
},
},
PathSessions: {
DirMode: 0755,
FileMode: 0444,
@ -62,6 +71,7 @@ var Perms = map[string]Perm{
PathTools: {DirMode: 0755, FileMode: 0755},
PathSessionFile: {
Files: map[string]os.FileMode{
"elevate": 0666,
"plan": 0666,
"ctl": 0666,
"prompt": 0666,

24
main.go
View File

@ -191,15 +191,7 @@ func runServer(sockPath string) {
})
var srv *server.Server
if !*no9p {
srv = server.New(server.Config{
Sink: sink,
SessionMgr: mgr,
RootStore: NewRootStore(),
InvalidateModels: modelCache.Invalidate,
ToolPrompt: execute.ToolPrompt,
})
}
// Server creation deferred until after elevate broker is ready (see below)
// Start D-Bus adapter.
// -tcp implies -nodbus (remote/headless deployments have no session bus).
@ -260,7 +252,18 @@ func runServer(sockPath string) {
} else {
defer sshProxy.Close()
}
_ = elevateBroker // used by 9P files (to be added)
}
// Create 9P server (after broker is ready so it can be passed in)
if !*no9p {
srv = server.New(server.Config{
Sink: sink,
SessionMgr: mgr,
RootStore: NewRootStore(),
ElevateBroker: elevateBroker,
InvalidateModels: modelCache.Invalidate,
ToolPrompt: execute.ToolPrompt,
})
}
// Prefetch models asynchronously at startup
@ -397,6 +400,7 @@ func NewRootStore() *fs.Tree {
return []os.DirEntry{
fs.FileEntry("backends", fs.Perms[fs.PathRoot].FileMode),
fs.FileEntry("ctl", 0666),
fs.DirEntry("elevate", 0755),
fs.FileEntry("generate", 0666),
fs.FileEntry("models", fs.Perms[fs.PathRoot].FileMode),
fs.FileEntry("help", fs.Perms[fs.PathRoot].FileMode),

197
server/elevate_tree.go Normal file
View File

@ -0,0 +1,197 @@
package server
import (
"context"
"fmt"
"os"
"strings"
"ollie/pkg/elevate"
fs "olliesrv/fs"
)
// elevateTree implements fs.FileTree for the /elevate/ namespace.
type elevateTree struct {
broker *elevate.Broker
}
func newElevateTree(broker *elevate.Broker) *elevateTree {
return &elevateTree{broker: broker}
}
func (t *elevateTree) Stat(name string) (os.FileInfo, error) {
switch {
case name == "policy":
return &fs.SyntheticFileInfo{Name_: "policy", Mode_: 0666}, nil
case name == "pending":
return &fs.SyntheticFileInfo{Name_: "pending", Mode_: 0755, IsDir_: true}, nil
case strings.HasPrefix(name, "pending/"):
id := strings.TrimPrefix(name, "pending/")
if t.broker.PendingByID(id) != nil {
return &fs.SyntheticFileInfo{Name_: id, Mode_: 0666}, nil
}
return nil, fmt.Errorf("%s: not found", name)
}
return nil, fmt.Errorf("%s: not found", name)
}
func (t *elevateTree) List() ([]os.DirEntry, error) {
return []os.DirEntry{
fs.FileEntry("policy", 0666),
fs.DirEntry("pending", 0755),
}, nil
}
func (t *elevateTree) Open(name string) (fs.File, error) {
switch {
case name == "policy":
return &elevatePolicyFile{broker: t.broker}, nil
case name == "pending":
return &elevatePendingDir{broker: t.broker}, nil
case strings.HasPrefix(name, "pending/"):
id := strings.TrimPrefix(name, "pending/")
req := t.broker.PendingByID(id)
if req == nil {
return nil, fmt.Errorf("%s: not found", name)
}
return &elevatePendingFile{broker: t.broker, req: req}, nil
}
return nil, fmt.Errorf("%s: not found", name)
}
func (t *elevateTree) Create(name string) error { return fmt.Errorf("cannot create in elevate") }
func (t *elevateTree) Delete(name string) error { return fmt.Errorf("cannot delete in elevate") }
func (t *elevateTree) Rename(_, _ string) error { return fmt.Errorf("cannot rename in elevate") }
// --- policy file ---
type elevatePolicyFile struct {
broker *elevate.Broker
}
func (f *elevatePolicyFile) Stat() (os.FileInfo, error) {
return &fs.SyntheticFileInfo{Name_: "policy", Mode_: 0666}, nil
}
func (f *elevatePolicyFile) Read() ([]byte, error) {
p := f.broker.GlobalPolicy().Global()
return p.Marshal()
}
func (f *elevatePolicyFile) Write(data []byte) error {
var p elevate.Policy
if err := elevate.ParsePolicy(data, &p); err != nil {
return err
}
return f.broker.GlobalPolicy().SetGlobal(p)
}
func (f *elevatePolicyFile) BlockingRead(_ context.Context, _ string) ([]byte, string, error) {
return nil, "", fmt.Errorf("not blocking")
}
func (f *elevatePolicyFile) OneShot() bool { return false }
func (f *elevatePolicyFile) IsBlocking() bool { return false }
func (f *elevatePolicyFile) Async() bool { return false }
// --- pending dir ---
type elevatePendingDir struct {
broker *elevate.Broker
}
func (f *elevatePendingDir) Stat() (os.FileInfo, error) {
return &fs.SyntheticFileInfo{Name_: "pending", Mode_: 0755, IsDir_: true}, nil
}
func (f *elevatePendingDir) Read() ([]byte, error) {
// List pending request IDs
var sb strings.Builder
for _, r := range f.broker.Pending() {
sb.WriteString(r.ID)
sb.WriteByte('\t')
sb.WriteString(r.Cmd)
sb.WriteByte('\n')
}
return []byte(sb.String()), nil
}
func (f *elevatePendingDir) Write([]byte) error { return fmt.Errorf("read-only") }
func (f *elevatePendingDir) BlockingRead(_ context.Context, _ string) ([]byte, string, error) {
return nil, "", fmt.Errorf("not blocking")
}
func (f *elevatePendingDir) OneShot() bool { return false }
func (f *elevatePendingDir) IsBlocking() bool { return false }
func (f *elevatePendingDir) Async() bool { return false }
// --- pending/<id> file ---
type elevatePendingFile struct {
broker *elevate.Broker
req *elevate.Request
}
func (f *elevatePendingFile) Stat() (os.FileInfo, error) {
return &fs.SyntheticFileInfo{Name_: f.req.ID, Mode_: 0666}, nil
}
func (f *elevatePendingFile) Read() ([]byte, error) {
return []byte(f.req.Summary() + "\n"), nil
}
func (f *elevatePendingFile) Write(data []byte) error {
cmd := strings.TrimSpace(string(data))
var res elevate.Resolution
switch cmd {
case "approve":
res = elevate.ResolveApprove
case "deny":
res = elevate.ResolveDeny
case "persist":
res = elevate.ResolvePersist
default:
return fmt.Errorf("unknown resolution: %s (use approve/deny/persist)", cmd)
}
if !f.broker.Resolve(f.req.ID, res) {
return fmt.Errorf("request already resolved or timed out")
}
return nil
}
func (f *elevatePendingFile) BlockingRead(_ context.Context, _ string) ([]byte, string, error) {
return nil, "", fmt.Errorf("not blocking")
}
func (f *elevatePendingFile) OneShot() bool { return false }
func (f *elevatePendingFile) IsBlocking() bool { return false }
func (f *elevatePendingFile) Async() bool { return false }
// --- s/{id}/elevate file (session policy) ---
type sessionElevateFile struct {
broker *elevate.Broker
sessionID string
}
func (f *sessionElevateFile) Stat() (os.FileInfo, error) {
return &fs.SyntheticFileInfo{Name_: "elevate", Mode_: 0666}, nil
}
func (f *sessionElevateFile) Read() ([]byte, error) {
p := f.broker.SessionPolicy(f.sessionID)
return p.Marshal()
}
func (f *sessionElevateFile) Write(data []byte) error {
var p elevate.Policy
if err := elevate.ParsePolicy(data, &p); err != nil {
return err
}
f.broker.SetSessionPolicy(f.sessionID, p)
return nil
}
func (f *sessionElevateFile) BlockingRead(_ context.Context, _ string) ([]byte, string, error) {
return nil, "", fmt.Errorf("not blocking")
}
func (f *sessionElevateFile) OneShot() bool { return false }
func (f *sessionElevateFile) IsBlocking() bool { return false }
func (f *sessionElevateFile) Async() bool { return false }

View File

@ -16,10 +16,11 @@ import (
"time"
"ollie/pkg/backend"
"ollie/pkg/elevate"
olog "ollie/pkg/log"
"olliesrv/session"
"ollie/pkg/paths"
"olliesrv/fs"
"olliesrv/session"
"9fans.net/go/plan9"
)
@ -69,6 +70,7 @@ type Server struct {
sink *olog.Sink
sessionMgr *session.Manager
rootStore *fs.Tree
elevateTree *elevateTree
groups map[string]map[string]bool // group → set of members
invalidateModels func()
toolPrompt func(string) string
@ -79,6 +81,7 @@ type Config struct {
Sink *olog.Sink
SessionMgr *session.Manager
RootStore *fs.Tree
ElevateBroker *elevate.Broker
InvalidateModels func()
ToolPrompt func(string) string
}
@ -97,6 +100,9 @@ func New(cfg Config) *Server {
invalidateModels: cfg.InvalidateModels,
toolPrompt: cfg.ToolPrompt,
}
if cfg.ElevateBroker != nil {
s.elevateTree = newElevateTree(cfg.ElevateBroker)
}
return s
}
@ -110,9 +116,13 @@ type fsRoute struct {
// routes returns the route table for the file tree namespace.
// Directory permissions come from the fs.Perms registry.
func (s *Server) routes() []fsRoute {
return []fsRoute{
routes := []fsRoute{
{fs.PathSessions, fs.Perms[fs.PathSessions].DirMode, func() FileTree { return s.sessionMgr.Tree() }},
}
if s.elevateTree != nil {
routes = append(routes, fsRoute{fs.PathElevate, fs.Perms[fs.PathElevate].DirMode, func() FileTree { return s.elevateTree }})
}
return routes
}
// route returns the tree and entry name for a path, or nil if no route matches.
@ -134,6 +144,15 @@ func (s *Server) route(path string) (FileTree, string) {
// openEntry opens a File for the given path.
func (s *Server) openEntry(path string) (File, error) {
// Intercept s/{id}/elevate — session policy via broker
if s.elevateTree != nil && strings.HasPrefix(path, fs.PathSessions) {
rel := strings.TrimPrefix(path, fs.PathSessions)
if parts := strings.SplitN(rel, "/", 2); len(parts) == 2 && parts[1] == "elevate" {
sessionID := parts[0]
return &sessionElevateFile{broker: s.elevateTree.broker, sessionID: sessionID}, nil
}
}
st, name := s.route(path)
if st == nil {
return nil, fmt.Errorf("%s: not found", path)