9p: permission model for session files

Session files now have owner=session_id, group=agent. Permission
checking is enforced on open for session files only:

  prompt/fifo.in: mode 0022 (group+other write, owner cannot)
  ctl:            mode 0222 (everyone can write)

Self-prompt rejection is now a natural consequence of the permission
model — the session (owner) has no write bit on its own prompt file.
Cross-agent prompting works via group 'agent' write bit. User access
works via 'other' write bit.

On attach, sessions are added to group 'agent', other users to 'user'.
Removed the explicit self-prompt check from clunk.
This commit is contained in:
Levi Neely 2026-05-19 14:22:12 +02:00
parent f9c9bc8969
commit a25dab6ee5
1 changed files with 80 additions and 12 deletions

View File

@ -325,6 +325,66 @@ func (s *Server) InGroup(group, user string) bool {
return s.groups[group][user] return s.groups[group][user]
} }
// fileOwnerGroup returns the uid and gid for a given path.
// Session files are owned by the session ID with group "agent".
// Everything else is owned by "ollie" with group "ollie".
func (s *Server) fileOwnerGroup(path string) (uid, gid string) {
if strings.HasPrefix(path, "/s/") {
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 2)
if len(parts) >= 1 && parts[0] != "new" {
// Check if parts[0] is a session (not a script like sh, bfg, etc.)
if sess := s.sessionStore.Session(parts[0]); sess != nil {
return parts[0], "agent"
}
}
}
return "ollie", "ollie"
}
// checkPerm verifies that uname has the requested access (mode) to path.
// mode is the 9P open mode: OREAD=0, OWRITE=1, ORDWR=2, OEXEC=3.
// Only enforced on session files (/s/{session_id}/...).
func (s *Server) checkPerm(uname, path string, mode uint8) error {
uid, gid := s.fileOwnerGroup(path)
if uid == "ollie" {
return nil // non-session files: no enforcement
}
dir := s.makeStat(path)
perm := uint32(dir.Mode) & 0777
// Determine which permission bits apply.
var bits uint32
if uname == uid {
bits = (perm >> 6) & 7
} else if s.InGroup(gid, uname) {
bits = (perm >> 3) & 7
} else {
bits = perm & 7
}
// Map 9P open mode to required permission bit.
omode := mode & 3
switch omode {
case plan9.OREAD:
if bits&4 == 0 {
return fmt.Errorf("permission denied")
}
case plan9.OWRITE:
if bits&2 == 0 {
return fmt.Errorf("permission denied")
}
case plan9.ORDWR:
if bits&6 != 6 {
return fmt.Errorf("permission denied")
}
case plan9.OEXEC:
if bits&1 == 0 {
return fmt.Errorf("permission denied")
}
}
return nil
}
// defaultTranscriptDir returns the transcript directory from OLLIE_TRANSCRIPT_PATH or the default. // defaultTranscriptDir returns the transcript directory from OLLIE_TRANSCRIPT_PATH or the default.
func defaultTranscriptDir() string { func defaultTranscriptDir() string {
@ -711,6 +771,14 @@ func (s *Server) attach(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
qid := plan9.Qid{Type: QTDir, Path: 0} qid := plan9.Qid{Type: QTDir, Path: 0}
cs.fids[fc.Fid] = &fid{path: "/", qid: qid} cs.fids[fc.Fid] = &fid{path: "/", qid: qid}
s.log.Debug("Tattach uname=%q", fc.Uname) s.log.Debug("Tattach uname=%q", fc.Uname)
// Assign group membership based on whether uname is a session ID.
if fc.Uname != "" {
if sess := s.sessionStore.Session(fc.Uname); sess != nil {
s.AddGroup("agent", fc.Uname)
} else {
s.AddGroup("user", fc.Uname)
}
}
return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: qid} return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: qid}
} }
@ -778,6 +846,10 @@ func (s *Server) open(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return errFcall(fc, "bad fid") return errFcall(fc, "bad fid")
} }
if err := s.checkPerm(cs.uname, f.path, fc.Mode); err != nil {
return errFcall(fc, err.Error())
}
f.mode = fc.Mode f.mode = fc.Mode
s.log.Debug("Topen fid=%d path=%q mode=%d", fc.Fid, f.path, fc.Mode) s.log.Debug("Topen fid=%d path=%q mode=%d", fc.Fid, f.path, fc.Mode)
return &plan9.Fcall{Type: plan9.Ropen, Tag: fc.Tag, Qid: f.qid} return &plan9.Fcall{Type: plan9.Ropen, Tag: fc.Tag, Qid: f.qid}
@ -1220,13 +1292,6 @@ func (s *Server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if writable { if writable {
s.log.Debug("Tclunk flush path=%q writeBuf=%d uname=%q", path, len(data), uname) s.log.Debug("Tclunk flush path=%q writeBuf=%d uname=%q", path, len(data), uname)
input := strings.TrimSpace(string(data)) 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) { if s.isAsyncWrite(path) {
go s.handleWrite(path, input, uname) //nolint:errcheck go s.handleWrite(path, input, uname) //nolint:errcheck
} else if err := s.handleWrite(path, input, uname); err != nil { } else if err := s.handleWrite(path, input, uname); err != nil {
@ -1579,8 +1644,10 @@ func (s *Server) makeStat(path string) plan9.Dir {
} }
} else { } else {
switch base { switch base {
case "ctl", "prompt", "fifo.in": case "ctl":
mode = 0200 mode = 0222 // owner+group+other write
case "prompt", "fifo.in":
mode = 0022 // group+other write; owner cannot write (no self-prompt)
case "chat", "usage", "cost", "ctxsz", "models", "fifo.out", "offset", "statewait", "systemprompt": case "chat", "usage", "cost", "ctxsz", "models", "fifo.out", "offset", "statewait", "systemprompt":
mode = 0444 mode = 0444
case "cfg": case "cfg":
@ -1622,13 +1689,14 @@ func (s *Server) makeStat(path string) plan9.Dir {
} }
} }
uid, gid := s.fileOwnerGroup(path)
dir := plan9.Dir{ dir := plan9.Dir{
Qid: qid, Qid: qid,
Mode: mode, Mode: mode,
Name: base, Name: base,
Uid: "ollie", Uid: uid,
Gid: "ollie", Gid: gid,
Muid: "ollie", Muid: uid,
} }
// For chat and tailable mutable files, report actual size and // For chat and tailable mutable files, report actual size and