9p: uname-based self-prompt rejection + ollie-9p-mount

Store Uname from Tattach on each connection. Reject writes to
s/{id}/prompt when the connection's uname matches the target session
(self-prompt). Cross-agent prompting remains allowed.

Add ollie-9p-mount: a FUSE-to-9P proxy that attaches with a custom
uname derived from $OLLIE_SESSION_ID. Each session gets a transparent
mount — tools use ordinary file I/O and the identity is carried by the
underlying 9P connection.

Also adds a group registry (AddGroup/RemoveGroup/InGroup) on the server
for future access control use cases.
This commit is contained in:
Levi Neely 2026-05-19 14:10:22 +02:00
parent c1f02cdee1
commit f9c9bc8969
6 changed files with 333 additions and 7 deletions

View File

@ -0,0 +1,97 @@
// ollie-9p-mount — per-session FUSE proxy that attaches to olliesrv with a custom uname.
//
// Each agent session gets its own mount. Tools running inside the session see
// the full ollie namespace through $OLLIE, but all 9P operations carry the
// session's identity (Uname). The server can then enforce access control
// (e.g. rejecting self-prompts).
//
// Usage:
//
// ollie-9p-mount [-u uname] [-s service] [-addr address] <mountpoint>
//
// If -u is not given, $OLLIE_SESSION_ID is used.
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"9fans.net/go/plan9/client"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
var (
userFlag = flag.String("u", "", "uname for 9P attach (default: $OLLIE_SESSION_ID)")
serviceFlag = flag.String("s", "ollie", "9P service name")
netFlag = flag.String("net", "", "network type (unix, tcp); empty = namespace")
addrFlag = flag.String("addr", "", "9P address; empty = namespace service")
)
func main() {
flag.Parse()
if flag.NArg() != 1 {
fmt.Fprintf(os.Stderr, "usage: ollie-9p-mount [-u uname] [-s service] <mountpoint>\n")
os.Exit(1)
}
mnt := flag.Arg(0)
uname := *userFlag
if uname == "" {
uname = os.Getenv("OLLIE_SESSION_ID")
}
if uname == "" {
fmt.Fprintf(os.Stderr, "ollie-9p-mount: no uname (-u or $OLLIE_SESSION_ID)\n")
os.Exit(1)
}
fsys, err := dial(uname)
if err != nil {
log.Fatalf("dial: %v", err)
}
os.MkdirAll(mnt, 0755)
root := &p9Dir{fsys: fsys, path: ""}
server, err := fs.Mount(mnt, root, &fs.Options{
MountOptions: fuse.MountOptions{
FsName: "ollie-9p",
Name: "ollie",
DisableXAttrs: true,
},
})
if err != nil {
log.Fatalf("mount: %v", err)
}
// Unmount on signal.
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sig
server.Unmount()
}()
server.Wait()
}
func dial(uname string) (*client.Fsys, error) {
var conn *client.Conn
var err error
if *addrFlag != "" {
n := *netFlag
if n == "" {
n = "tcp"
}
conn, err = client.Dial(n, *addrFlag)
} else {
conn, err = client.DialService(*serviceFlag)
}
if err != nil {
return nil, err
}
return conn.Attach(nil, uname, "")
}

174
cmd/ollie-9p-mount/node.go Normal file
View File

@ -0,0 +1,174 @@
package main
import (
"context"
"sync"
"syscall"
"9fans.net/go/plan9"
"9fans.net/go/plan9/client"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
// p9Dir is a FUSE directory node backed by a 9P path.
type p9Dir struct {
fs.Inode
fsys *client.Fsys
path string // 9P path (empty string = root)
}
// p9File is a FUSE file node backed by a 9P path.
type p9File struct {
fs.Inode
fsys *client.Fsys
path string
mu sync.Mutex
}
var _ = (fs.NodeGetattrer)((*p9Dir)(nil))
var _ = (fs.NodeReaddirer)((*p9Dir)(nil))
var _ = (fs.NodeLookuper)((*p9Dir)(nil))
var _ = (fs.NodeGetattrer)((*p9File)(nil))
var _ = (fs.NodeOpener)((*p9File)(nil))
var _ = (fs.NodeReader)((*p9File)(nil))
var _ = (fs.NodeWriter)((*p9File)(nil))
var _ = (fs.NodeSetattrer)((*p9File)(nil))
func p9path(base, name string) string {
if base == "" {
return name
}
return base + "/" + name
}
// --- p9Dir ---
func (d *p9Dir) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Mode = 0555 | syscall.S_IFDIR
return 0
}
func (d *p9Dir) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) {
path := d.path
if path == "" {
path = "/"
}
fid, err := d.fsys.Open(path, plan9.OREAD)
if err != nil {
return nil, syscall.EIO
}
defer fid.Close()
dirs, err := fid.Dirreadall()
if err != nil {
return nil, syscall.EIO
}
entries := make([]fuse.DirEntry, len(dirs))
for i, dir := range dirs {
m := uint32(dir.Mode) & 0777
if dir.Mode&plan9.DMDIR != 0 {
m |= syscall.S_IFDIR
}
entries[i] = fuse.DirEntry{Name: dir.Name, Mode: m}
}
return fs.NewListDirStream(entries), 0
}
func (d *p9Dir) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
child := p9path(d.path, name)
dir, err := d.fsys.Stat(child)
if err != nil {
return nil, syscall.ENOENT
}
if dir.Mode&plan9.DMDIR != 0 {
out.Mode = uint32(dir.Mode)&0777 | syscall.S_IFDIR
node := &p9Dir{fsys: d.fsys, path: child}
return d.NewInode(ctx, node, fs.StableAttr{Mode: syscall.S_IFDIR}), 0
}
out.Mode = uint32(dir.Mode) & 0777
out.Size = dir.Length
node := &p9File{fsys: d.fsys, path: child}
return d.NewInode(ctx, node, fs.StableAttr{}), 0
}
// --- p9File ---
func (f *p9File) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
dir, err := f.fsys.Stat(f.path)
if err != nil {
return syscall.ENOENT
}
// Report all files as rw so the kernel doesn't block writes;
// the 9P server enforces actual permissions.
out.Mode = 0666
out.Size = dir.Length
return 0
}
func (f *p9File) Setattr(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
// Accept truncate silently — 9P files are append/overwrite on write.
out.Mode = 0666
return 0
}
func (f *p9File) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
return nil, fuse.FOPEN_DIRECT_IO, 0
}
func (f *p9File) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
f.mu.Lock()
defer f.mu.Unlock()
fid, err := f.fsys.Open(f.path, plan9.OREAD)
if err != nil {
return nil, syscall.EIO
}
defer fid.Close()
// The 9fans client doesn't support Seek; read from offset by reading and discarding.
// For small offsets this is fine; large seeks on big files would need a smarter approach.
if off > 0 {
buf := make([]byte, min(off, 8192))
remaining := off
for remaining > 0 {
n := int64(len(buf))
if n > remaining {
n = remaining
}
nr, err := fid.Read(buf[:n])
if err != nil || nr == 0 {
break
}
remaining -= int64(nr)
}
}
n, err := fid.Read(dest)
if err != nil && n == 0 {
return nil, syscall.EIO
}
return fuse.ReadResultData(dest[:n]), 0
}
func (f *p9File) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
f.mu.Lock()
defer f.mu.Unlock()
fid, err := f.fsys.Open(f.path, plan9.OWRITE)
if err != nil {
return 0, syscall.EACCES
}
n, err := fid.Write(data)
if err != nil {
fid.Close()
return 0, syscall.EIO
}
if err := fid.Close(); err != nil {
return 0, syscall.EACCES
}
return uint32(n), 0
}
func min(a, b int64) int64 {
if a < b {
return a
}
return b
}

6
go.mod
View File

@ -4,9 +4,13 @@ go 1.25.6
require (
9fans.net/go v0.0.7
github.com/hanwen/go-fuse/v2 v2.10.1
ollie v0.0.0-00010101000000-000000000000
)
require gopkg.in/yaml.v3 v3.0.1 // indirect
require (
golang.org/x/sys v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace ollie => ../core

8
go.sum
View File

@ -3,6 +3,12 @@
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/hanwen/go-fuse/v2 v2.10.1 h1:QAqZuc9+aBtTou+OPruU/hkYQYCkgPtQd2QaepHkTTs=
github.com/hanwen/go-fuse/v2 v2.10.1/go.mod h1:aU7NkGYZUmuJrZapoI3mEcNve7PZTySUOLBuch/vR6U=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@ -25,6 +31,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

3
mkfile
View File

@ -4,8 +4,9 @@ all:V: install
build:V:
go build -o $INSTALL_PATH/olliesrv .
go build -o $INSTALL_PATH/ollie-9p-mount ./cmd/ollie-9p-mount/
install:V: build
clean:V:
rm -f $INSTALL_PATH/olliesrv
rm -f $INSTALL_PATH/olliesrv $INSTALL_PATH/ollie-9p-mount

View File

@ -207,6 +207,7 @@ type connState struct {
ctx context.Context
cancel context.CancelFunc
pending map[uint16]context.CancelFunc // in-flight request cancels, keyed by tag
uname string // user principal from Tattach
}
// Server is the 9P server for ollie sessions.
@ -228,6 +229,7 @@ type Server struct {
tmpStore Store
strict bool
yolo bool
groups map[string]map[string]bool // group → set of members
}
// ServerOption configures the 9P server.
@ -260,6 +262,7 @@ func New(sink *olog.Sink, opts ...ServerOption) *Server {
skillStore: NewSkillStore(),
transcriptStore: NewFlatDirStore(transcriptDir, 0444),
tmpStore: NewFlatDirStore(tmpDir, 0600),
groups: make(map[string]map[string]bool),
}
for _, o := range opts {
o(s)
@ -293,6 +296,35 @@ func New(sink *olog.Sink, opts ...ServerOption) *Server {
return s
}
// AddGroup adds a user to a group.
func (s *Server) AddGroup(group, user string) {
s.mu.Lock()
if s.groups[group] == nil {
s.groups[group] = make(map[string]bool)
}
s.groups[group][user] = true
s.mu.Unlock()
}
// RemoveGroup removes a user from a group.
func (s *Server) RemoveGroup(group, user string) {
s.mu.Lock()
if m := s.groups[group]; m != nil {
delete(m, user)
if len(m) == 0 {
delete(s.groups, group)
}
}
s.mu.Unlock()
}
// InGroup returns true if user is a member of group.
func (s *Server) InGroup(group, user string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.groups[group][user]
}
// defaultTranscriptDir returns the transcript directory from OLLIE_TRANSCRIPT_PATH or the default.
func defaultTranscriptDir() string {
@ -675,8 +707,10 @@ func boolToDir(isDir bool) uint32 {
func (s *Server) attach(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.uname = fc.Uname
qid := plan9.Qid{Type: QTDir, Path: 0}
cs.fids[fc.Fid] = &fid{path: "/", qid: qid}
s.log.Debug("Tattach uname=%q", fc.Uname)
return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: qid}
}
@ -1181,13 +1215,21 @@ func (s *Server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
}
delete(cs.fids, fc.Fid)
}
uname := cs.uname
cs.mu.Unlock()
if writable {
s.log.Debug("Tclunk flush path=%q writeBuf=%d", path, len(data))
s.log.Debug("Tclunk flush path=%q writeBuf=%d uname=%q", path, len(data), uname)
input := strings.TrimSpace(string(data))
// Reject self-prompt before async dispatch so the error reaches the client.
if uname != "" && strings.HasPrefix(path, "/s/") {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) == 3 && parts[2] == "prompt" && uname == parts[1] {
return errFcall(fc, "self-prompt rejected")
}
}
if s.isAsyncWrite(path) {
go s.handleWrite(path, input) //nolint:errcheck
} else if err := s.handleWrite(path, input); err != nil {
go s.handleWrite(path, input, uname) //nolint:errcheck
} else if err := s.handleWrite(path, input, uname); err != nil {
s.log.Debug("Tclunk handleWrite err=%v", err)
return errFcall(fc, err.Error())
}
@ -1269,8 +1311,8 @@ func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
// handleWrite processes a fully-assembled write payload for the given path.
// Called synchronously from clunk; prompt writes are the exception (spawned
// as a goroutine because they block for the entire agent turn).
func (s *Server) handleWrite(path, input string) error {
s.log.Debug("handleWrite path=%q input_len=%d", path, len(input))
func (s *Server) handleWrite(path, input, uname string) error {
s.log.Debug("handleWrite path=%q input_len=%d uname=%q", path, len(input), uname)
if path == "/s/new" {
if input == "" {