Remove sudo mechanism from bypass system

The sudo credential broker was never functional and added complexity
without value. This removes:

- Sudo field from bypass Request structs (broker, client, toolsrv)
- Sudo parameter from EvaluateRequest interface and implementations
- Sudo/ResetsCounter fields from MetaFile and Variant structs
- sudo: true from system_logs.meta variants
- All sudo documentation from writing-tools.md, tool-registry.md,
  core.md, evolution.md, and misc.md

Bypass remains fully functional for sandbox escapes. Tools that need
elevated privileges should handle that internally or be run manually.
This commit is contained in:
Levi Neely 2026-08-12 08:58:41 +02:00
parent 62a7d0d13f
commit 9bb3cd76b8
22 changed files with 451 additions and 154 deletions

View File

@ -177,7 +177,7 @@ func (b *Broker) recordDenial(sessionID string) {
// EvaluateRequest evaluates a bypass request from toolsrv and returns the decision.
// Execution happens in toolsrv, not here.
// Returns (approved, error). If needs user approval, blocks until resolved or timeout.
func (b *Broker) EvaluateRequest(sessionID, cmd, cwd string, env map[string]string, sudo bool) (bool, error) {
func (b *Broker) EvaluateRequest(sessionID, cmd, cwd string, env map[string]string) (bool, error) {
// Validate session
if sessionID == "" {
return false, fmt.Errorf("no session identity")
@ -206,7 +206,6 @@ func (b *Broker) EvaluateRequest(sessionID, cmd, cwd string, env map[string]stri
Cwd: cwd,
Env: env,
SessionID: sessionID,
Sudo: sudo,
CreatedAt: time.Now(),
resolved: make(chan Resolution, 1),
}

View File

@ -31,7 +31,7 @@ func TestBrokerAutoApprove(t *testing.T) {
b.GlobalPolicy().AddGlobal(Rule{Cmd: "echo *"})
// Evaluate a matching command
approved, err := b.EvaluateRequest("test-session", "echo hello", os.TempDir(), nil, false)
approved, err := b.EvaluateRequest("test-session", "echo hello", os.TempDir(), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -44,7 +44,7 @@ func TestBrokerDenyNoSession(t *testing.T) {
b := startTestBroker(t)
// Evaluate with no session ID
approved, err := b.EvaluateRequest("", "echo hello", os.TempDir(), nil, false)
approved, err := b.EvaluateRequest("", "echo hello", os.TempDir(), nil)
if err == nil {
t.Fatal("expected error for empty session")
}
@ -60,7 +60,7 @@ func TestBrokerDenyInvalidSession(t *testing.T) {
b := startTestBroker(t)
// Evaluate with a session ID that doesn't exist
approved, err := b.EvaluateRequest("nonexistent-session", "echo hello", os.TempDir(), nil, false)
approved, err := b.EvaluateRequest("nonexistent-session", "echo hello", os.TempDir(), nil)
if err == nil {
t.Fatal("expected error for invalid session")
}
@ -81,7 +81,7 @@ func TestBrokerPendingAndResolve(t *testing.T) {
err error
})
go func() {
approved, err := b.EvaluateRequest("test-session", "rm -rf /", os.TempDir(), nil, false)
approved, err := b.EvaluateRequest("test-session", "rm -rf /", os.TempDir(), nil)
resultCh <- struct {
approved bool
err error
@ -116,7 +116,7 @@ func TestBrokerApprove(t *testing.T) {
err error
})
go func() {
approved, err := b.EvaluateRequest("test-session", "echo approved", os.TempDir(), nil, false)
approved, err := b.EvaluateRequest("test-session", "echo approved", os.TempDir(), nil)
resultCh <- struct {
approved bool
err error
@ -149,7 +149,7 @@ func TestBrokerPersist(t *testing.T) {
// First request: no policy, goes to pending
resultCh := make(chan bool)
go func() {
approved, _ := b.EvaluateRequest("test-session", "echo persist-me", os.TempDir(), nil, false)
approved, _ := b.EvaluateRequest("test-session", "echo persist-me", os.TempDir(), nil)
resultCh <- approved
}()
@ -167,7 +167,7 @@ func TestBrokerPersist(t *testing.T) {
}
// Second request with same command: should auto-approve (no pending)
approved, err := b.EvaluateRequest("test-session", "echo persist-me", os.TempDir(), nil, false)
approved, err := b.EvaluateRequest("test-session", "echo persist-me", os.TempDir(), nil)
if err != nil {
t.Fatalf("second request error: %v", err)
}

View File

@ -39,7 +39,6 @@ type Request struct {
Cwd string
Env map[string]string
SessionID string
Sudo bool
CreatedAt time.Time
// Resolution channel — exactly one value sent when resolved.

View File

@ -121,7 +121,7 @@ func (s *Session) StartBypassLoop(broker BypassEvaluator) {
// BypassEvaluator evaluates bypass requests. Implemented by bypass.Broker.
type BypassEvaluator interface {
EvaluateRequest(sessionID, cmd, cwd string, env map[string]string, sudo bool) (bool, error)
EvaluateRequest(sessionID, cmd, cwd string, env map[string]string) (bool, error)
}
func (s *Session) runBypassLoop(broker BypassEvaluator) {
@ -151,7 +151,7 @@ func (s *Session) runBypassLoop(broker BypassEvaluator) {
}
// Evaluate the request
approved, evalErr := broker.EvaluateRequest(s.ID, req.Cmd, req.Cwd, req.Env, req.Sudo)
approved, evalErr := broker.EvaluateRequest(s.ID, req.Cmd, req.Cwd, req.Env)
// Send resolution back to toolsrv
resolveConn := s.DialToolServer()

View File

@ -12,11 +12,10 @@ import (
// Request represents a bypass request.
type Request struct {
ID string `json:"id"`
Cmd string `json:"cmd"`
Cwd string `json:"cwd"`
Env map[string]string `json:"env,omitempty"`
Sudo bool `json:"sudo,omitempty"`
ID string `json:"id"`
Cmd string `json:"cmd"`
Cwd string `json:"cwd"`
Env map[string]string `json:"env,omitempty"`
// Resolution state (not serialized)
done chan struct{}
@ -33,13 +32,12 @@ var (
// Submit submits a bypass request and blocks until resolved.
// Returns (approved, error). If denied, approved is false and error is nil.
// If there's an error (e.g., timeout, connection lost), error is non-nil.
func Submit(cmd, cwd string, env map[string]string, sudo bool) (bool, error) {
func Submit(cmd, cwd string, env map[string]string) (bool, error) {
req := &Request{
ID: nextID(),
Cmd: cmd,
Cwd: cwd,
Env: env,
Sudo: sudo,
done: make(chan struct{}),
}

View File

@ -84,16 +84,6 @@ func ExecuteTool(ctx context.Context, info toolsrv.ToolInfo, args json.RawMessag
args, _ = json.Marshal(argMap)
}
// Check if tool requires sudo
needsSudo := false
if m, err := toolsrv.LoadMetaFile(info.Name); err == nil && m != nil {
resolved := m.Resolve()
if resolved != nil && resolved.Sudo {
needsSudo = true
bypassed = true
}
}
stdinData := string(args)
cwd := cfg.CWD
if cwd == "" {
@ -101,12 +91,9 @@ func ExecuteTool(ctx context.Context, info toolsrv.ToolInfo, args json.RawMessag
}
var result string
if needsSudo {
sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", toolPath, stdinData)
result, err = executeBypassDirect(ctx, sudoCode, cwd, cfg.Env, timeout, true)
} else if bypassed {
if bypassed {
bypassCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", toolPath, stdinData)
result, err = executeBypassDirect(ctx, bypassCode, cwd, cfg.Env, timeout, false)
result, err = executeBypassDirect(ctx, bypassCode, cwd, cfg.Env, timeout)
} else {
result, err = executeSandboxed(ctx, toolPath, stdinData, cwd, cfg.Env, timeout, cfg.Yolo, cfg.Output, cfg.Started)
}
@ -240,7 +227,7 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE
// executeBypassDirect requests bypass approval and executes the command directly.
// The approval comes from olliesrv via the 9P bypass/pending and bypass/resolve files.
func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[string]string, timeout int, sudo bool) (string, error) {
func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[string]string, timeout int) (string, error) {
// Build environment map
envMap := make(map[string]string)
for _, kv := range os.Environ() {
@ -253,7 +240,7 @@ func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[stri
}
// Submit bypass request and wait for approval
approved, err := bypass.Submit(cmd, cwd, envMap, sudo)
approved, err := bypass.Submit(cmd, cwd, envMap)
if err != nil {
return "", fmt.Errorf("bypass request failed: %w", err)
}
@ -268,17 +255,12 @@ func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[stri
defer cancel()
}
return executeDirectUnsandboxed(ctx, cmd, cwd, envMap, sudo)
return executeDirectUnsandboxed(ctx, cmd, cwd, envMap)
}
// executeDirectUnsandboxed runs a command without any sandbox.
func executeDirectUnsandboxed(ctx context.Context, cmd, cwd string, env map[string]string, sudo bool) (string, error) {
var execCmd *osExec.Cmd
if sudo {
execCmd = osExec.CommandContext(ctx, "sudo", "-E", "bash", "-c", cmd)
} else {
execCmd = osExec.CommandContext(ctx, "bash", "-c", cmd)
}
func executeDirectUnsandboxed(ctx context.Context, cmd, cwd string, env map[string]string) (string, error) {
execCmd := osExec.CommandContext(ctx, "bash", "-c", cmd)
execCmd.Dir = cwd
if len(env) > 0 {
execCmd.Env = make([]string, 0, len(env))

View File

@ -133,7 +133,7 @@ func Spec(srv *Server) virtfs.FsNodeDecl {
),
virtfs.DirNode("bypass",
virtfs.FileNode("pending", 0444,
virtfs.Doc("Blocks until bypass request; returns JSON {id, cmd, cwd, env, sudo}"),
virtfs.Doc("Blocks until bypass request; returns JSON {id, cmd, cwd, env}"),
virtfs.BlockOnce(func(_ context.Context, _ string) ([]byte, string, error) {
req := bypass.NextPending()
data, err := json.Marshal(req)

View File

@ -16,8 +16,7 @@
"grep": {"type": "string", "description": "Filter output by pattern"}
}
},
"cmd": "system_logs_journald",
"sudo": true
"cmd": "system_logs_journald"
},
{
"match": {"binary": "dmesg"},
@ -31,8 +30,7 @@
"grep": {"type": "string", "description": "Filter output by pattern"}
}
},
"cmd": "system_logs_dmesg",
"sudo": true
"cmd": "system_logs_dmesg"
}
]
}

View File

@ -426,7 +426,7 @@ Runs a single bash command in a sandbox.
Before execution, inline code is checked against dangerous patterns:
- **Universal**: `mkfs`, `dd if=/dev/`, `sudo/su`, `/etc/shadow`
- **Universal**: `mkfs`, `dd if=/dev/`, `/etc/shadow`
- **Bash**: recursive force-delete of system paths, fork bombs, `/dev/sd` writes, eval injection
- **Python**: `shutil.rmtree("/")`, subprocess rm -rf, os.remove of system paths
- **Perl**: system/exec rm -rf, backtick rm, unlink system paths

View File

@ -347,13 +347,6 @@ executable. Enables one `.meta` to work across heterogeneous hosts — the
contract adapts to capabilities. Critical for `ollie-remote` deployments where
the remote host may have different tools, package managers, or init systems.
### Sudo credential broker
Tools declare `"sudo": true` in their `.meta` to require root privileges.
Dispatch becomes a two-gate chain: bypass approval → credential prompt → `sudo -S`.
Credentials are requested via `kdialog`/`zenity` on the local desktop, or forwarded
over SSH for remote execution. The tool itself has no knowledge of sudo — the
privilege wrapping is entirely in the dispatch layer.
## Principles That Emerged
1. **Filesystem-as-API** — everything is read/write on synthetic files. No
custom protocols needed.
@ -987,7 +980,7 @@ Agent ctl commands: `stop`, `compact`, `clear`, `inject`, `agent`, `model`, `mod
### elevate → bypass
The sandbox escape mechanism was renamed from `elevate` to `bypass` throughout (package, namespace, env vars, tool args, all docs). Eliminates confusion with sudo.
The sandbox escape mechanism was renamed from `elevate` to `bypass` throughout (package, namespace, env vars, tool args, all docs).
### Streaming Fix

View File

@ -55,7 +55,6 @@ Each tool has a `.meta` JSON sidecar file alongside the executable:
| `tier` | Result cacheability: `cold`, `warm`, or `hot` (default: hot) |
| `readOnly` | Safe for parallel execution with other read tools |
| `cmd` | Executable path or name override |
| `sudo` | Requires root privileges; implies bypass |
| `variants` | Conditional definitions for heterogeneous hosts |
### Runtime access

View File

@ -56,27 +56,6 @@ The `.meta` file (`file_glob.meta`):
Everything is sandboxed by default via Landlock. Tools that need to escape
request bypass explicitly (`"bypass": true`).
### Privileged tools: `sudo`
Tools that need root privileges declare `"sudo": true` in their `.meta`. The
dispatch chain becomes a two-gate sequence: bypass approval (escape sandbox)
→ credential prompt → `sudo -S` execution. The tool itself has no knowledge of
sudo — the privilege wrapping is entirely in the dispatch layer.
```json
{
"description": "Read system logs (dmesg).",
"sudo": true,
"cmd": "system_logs_dmesg",
"args": {"type":"object","properties":{"lines":{"type":"string"}}},
"readOnly": true
}
```
Credentials are prompted via `kdialog`/`zenity` on desktop, or forwarded over
SSH for remote execution. See [`doc/writing-tools.md`](doc/writing-tools.md) for
details.
### Host-conditional variants
A single `.meta` file can describe a tool that works differently on different

View File

@ -8,7 +8,6 @@ tool, you don't edit config files, you don't restart a server. You write a
- **What** the tool does (description, prompt, args schema)
- **Where** to find it (cmd, co-located executable, or PATH)
- **When** it's available (match conditions per variant)
- **What privileges** it needs (sudo)
The registry reads the `.meta` file, presents the tool to the model, and
executes the binary when called. The model sees a typed function. The runtime
@ -60,7 +59,6 @@ reads only `.meta` files — it never inspects the executable itself.
| `tier` | `"hot"\|"warm"\|"cold"` | Context retention tier (default: hot) |
| `readOnly` | bool | Safe for parallel execution with other read tools |
| `cmd` | string | Executable path or name (see Resolution below) |
| `sudo` | bool | Requires root privileges; implies bypass |
| `variants` | array | Conditional definitions for heterogeneous hosts (see Variants below) |
## Executable resolution
@ -248,68 +246,6 @@ Landlock but still as the current user.
}
```
### Sudo: run as root
Tools that need root privileges declare `"sudo": true` in their `.meta`.
This **implies bypass** — can't sudo inside a sandbox. The dispatch chain
becomes two sequential gates:
```
tool call → bypass gate → credential gate → sudo -S tool
```
1. **Elevation gate** — broker asks: "approve escaping the sandbox?" User
approves or denies. If denied, the credential gate is never reached.
2. **Credential gate** — broker prompts for sudo password (via `kdialog`,
`zenity`, or terminal). The password is sent over an encrypted channel
(SSH for remote) and piped to `sudo -S`.
3. **Execution** — tool runs as root, output streams back to the agent.
The tool itself has **no knowledge of sudo**. It reads JSON from stdin and
writes to stdout as always. The privilege wrapping is entirely in the dispatch
layer.
```json
{
"description": "Read system logs (dmesg).",
"sudo": true,
"cmd": "system_logs_dmesg",
"args": {
"type": "object",
"properties": {
"lines": {"type": "string", "description": "Number of lines"},
"source": {"type": "string", "description": "Log source"}
}
},
"readOnly": true
}
```
### Network transparency for privileges
The same `.meta` works on any host:
- **Local desktop**: kdialog or zenity prompts for password
- **Remote (ollie-remote)**: credential request travels back over the
encrypted SSH tunnel to the local broker, which prompts the user
- **Headless**: terminal-based fallback (or `sudo -n` if NOPASSWD is
configured)
No assumptions about GUI availability, init system, or sudoers configuration.
The tool adapts to what's available.
### Example: full privilege chain
```bash
# system_logs calls dmesg with sudo
system_logs(source="dmesg", lines="10")
# Behind the scenes:
# 1. Elevation approved → "escape sandbox"
# 2. kdialog prompts for sudo password → "credentials accepted"
# 3. sudo -S dmesg --time-format iso | tail -10
# 4. Output streams to agent
```
## Environment Variables
Tools receive:

BIN
experiments/auth9p/client Executable file

Binary file not shown.

View File

@ -0,0 +1,125 @@
// Client: 9P client that authenticates via Tauth
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net"
"os"
"9fans.net/go/plan9"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: client <secret>")
fmt.Println(" client new (generate new secret)")
os.Exit(1)
}
secret := os.Args[1]
if secret == "new" {
b := make([]byte, 16)
rand.Read(b)
secret = hex.EncodeToString(b)
fmt.Printf("Generated secret: %s\n", secret)
}
sockPath := "/tmp/auth9p-test.sock"
conn, err := net.Dial("unix", sockPath)
if err != nil {
log.Fatalf("dial: %v", err)
}
defer conn.Close()
fmt.Println("Connected to server")
// Tversion
tag := uint16(1)
if err := plan9.WriteFcall(conn, &plan9.Fcall{
Type: plan9.Tversion,
Tag: plan9.NOTAG,
Msize: 8192,
Version: "9P2000",
}); err != nil {
log.Fatal(err)
}
resp, _ := plan9.ReadFcall(conn)
fmt.Printf("Version: %v\n", resp)
// Tauth
afid := uint32(1)
if err := plan9.WriteFcall(conn, &plan9.Fcall{
Type: plan9.Tauth,
Tag: tag,
Afid: afid,
Uname: "agent",
Aname: "",
}); err != nil {
log.Fatal(err)
}
tag++
resp, _ = plan9.ReadFcall(conn)
fmt.Printf("Auth: %v\n", resp)
if resp.Type == plan9.Rerror {
log.Fatalf("Tauth failed: %s", resp.Ename)
}
// Write secret to auth fid
if err := plan9.WriteFcall(conn, &plan9.Fcall{
Type: plan9.Twrite,
Tag: tag,
Fid: afid,
Data: []byte(secret),
Count: uint32(len(secret)),
}); err != nil {
log.Fatal(err)
}
tag++
resp, _ = plan9.ReadFcall(conn)
fmt.Printf("Write secret: %v\n", resp)
if resp.Type == plan9.Rerror {
log.Fatalf("Auth failed: %s", resp.Ename)
}
// Read token from auth fid
if err := plan9.WriteFcall(conn, &plan9.Fcall{
Type: plan9.Tread,
Tag: tag,
Fid: afid,
Offset: 0,
Count: 1024,
}); err != nil {
log.Fatal(err)
}
tag++
resp, _ = plan9.ReadFcall(conn)
fmt.Printf("Read token: %v\n", resp)
token := string(resp.Data)
fmt.Printf("Session token: %s", token)
// Tattach
fid := uint32(2)
if err := plan9.WriteFcall(conn, &plan9.Fcall{
Type: plan9.Tattach,
Tag: tag,
Fid: fid,
Afid: afid,
Uname: "agent",
Aname: "",
}); err != nil {
log.Fatal(err)
}
tag++
resp, _ = plan9.ReadFcall(conn)
fmt.Printf("Attach: %v\n", resp)
if resp.Type == plan9.Rerror {
log.Fatalf("Attach failed: %s", resp.Ename)
}
fmt.Println("\n✓ Successfully authenticated and attached!")
fmt.Printf("Use this secret to reconnect: %s\n", secret)
}

View File

@ -0,0 +1,5 @@
module auth9p
go 1.23
require 9fans.net/go v0.0.5

34
experiments/auth9p/go.sum Normal file
View File

@ -0,0 +1,34 @@
9fans.net/go v0.0.5 h1:u0H3Et5NowdVm56FDD+aWcDOao9zEo+zolNFsr8qyQk=
9fans.net/go v0.0.5/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0=
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=
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=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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/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=
golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

BIN
experiments/auth9p/server Executable file

Binary file not shown.

View File

@ -0,0 +1,216 @@
// Server: 9P server with Tauth-based authentication
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log"
"net"
"os"
"sync"
"9fans.net/go/plan9"
)
type Server struct {
mu sync.Mutex
secret string // set on first auth, verified on subsequent
token string // session token returned after auth
}
func main() {
sockPath := "/tmp/auth9p-test.sock"
os.Remove(sockPath)
ln, err := net.Listen("unix", sockPath)
if err != nil {
log.Fatal(err)
}
defer ln.Close()
defer os.Remove(sockPath)
fmt.Printf("Server listening on %s\n", sockPath)
fmt.Println("Waiting for connections...")
srv := &Server{}
for {
conn, err := ln.Accept()
if err != nil {
log.Printf("accept: %v", err)
continue
}
go srv.handle(conn)
}
}
func (s *Server) handle(conn net.Conn) {
defer conn.Close()
fmt.Printf("New connection from %v\n", conn.RemoteAddr())
// Track fids
fids := make(map[uint32]*Fid)
authFids := make(map[uint32]*AuthFid)
for {
// Read 9P message
fc, err := plan9.ReadFcall(conn)
if err != nil {
if err != io.EOF {
log.Printf("read: %v", err)
}
return
}
fmt.Printf("← %v\n", fc)
var resp *plan9.Fcall
switch fc.Type {
case plan9.Tversion:
resp = &plan9.Fcall{
Type: plan9.Rversion,
Tag: fc.Tag,
Msize: fc.Msize,
Version: "9P2000",
}
case plan9.Tauth:
// Create auth fid
authFids[fc.Afid] = &AuthFid{
uname: fc.Uname,
aname: fc.Aname,
}
resp = &plan9.Fcall{
Type: plan9.Rauth,
Tag: fc.Tag,
Qid: plan9.Qid{Type: plan9.QTAUTH, Vers: 0, Path: uint64(fc.Afid)},
}
case plan9.Tattach:
// Check auth
af, ok := authFids[fc.Afid]
if !ok || !af.authenticated {
resp = errorResp(fc.Tag, "not authenticated")
break
}
fids[fc.Fid] = &Fid{path: "/"}
resp = &plan9.Fcall{
Type: plan9.Rattach,
Tag: fc.Tag,
Qid: plan9.Qid{Type: plan9.QTDIR, Vers: 0, Path: 0},
}
fmt.Printf("✓ Client attached successfully (token: %s)\n", af.token)
case plan9.Tread:
// Reading from auth fid returns the token
if af, ok := authFids[fc.Fid]; ok {
if !af.authenticated {
resp = errorResp(fc.Tag, "write secret first")
} else {
data := []byte(af.token + "\n")
resp = &plan9.Fcall{
Type: plan9.Rread,
Tag: fc.Tag,
Data: data,
Count: uint32(len(data)),
}
}
} else {
resp = &plan9.Fcall{
Type: plan9.Rread,
Tag: fc.Tag,
Data: []byte("hello from server\n"),
Count: 18,
}
}
case plan9.Twrite:
// Writing to auth fid sets/verifies secret
if af, ok := authFids[fc.Fid]; ok {
clientSecret := string(fc.Data)
resp = s.handleAuthWrite(fc.Tag, af, clientSecret)
} else {
resp = &plan9.Fcall{
Type: plan9.Rwrite,
Tag: fc.Tag,
Count: fc.Count,
}
}
case plan9.Tclunk:
delete(fids, fc.Fid)
delete(authFids, fc.Fid)
resp = &plan9.Fcall{Type: plan9.Rclunk, Tag: fc.Tag}
default:
resp = errorResp(fc.Tag, fmt.Sprintf("unhandled message type %d", fc.Type))
}
fmt.Printf("→ %v\n", resp)
if err := plan9.WriteFcall(conn, resp); err != nil {
log.Printf("write: %v", err)
return
}
}
}
func (s *Server) handleAuthWrite(tag uint16, af *AuthFid, clientSecret string) *plan9.Fcall {
s.mu.Lock()
defer s.mu.Unlock()
if s.secret == "" {
// First auth - set the secret
s.secret = clientSecret
s.token = randomToken()
af.authenticated = true
af.token = s.token
fmt.Printf("✓ Secret established (first connection)\n")
return &plan9.Fcall{
Type: plan9.Rwrite,
Tag: tag,
Count: uint32(len(clientSecret)),
}
}
// Subsequent auth - verify secret
if clientSecret != s.secret {
fmt.Printf("✗ Secret mismatch!\n")
return errorResp(tag, "authentication failed")
}
af.authenticated = true
af.token = s.token
fmt.Printf("✓ Secret verified (reconnection)\n")
return &plan9.Fcall{
Type: plan9.Rwrite,
Tag: tag,
Count: uint32(len(clientSecret)),
}
}
type Fid struct {
path string
}
type AuthFid struct {
uname string
aname string
authenticated bool
token string
}
func errorResp(tag uint16, msg string) *plan9.Fcall {
return &plan9.Fcall{
Type: plan9.Rerror,
Tag: tag,
Ename: msg,
}
}
func randomToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}

40
experiments/auth9p/test.sh Executable file
View File

@ -0,0 +1,40 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
rm -f /tmp/auth9p-test.sock
echo "Starting server..."
./server > /tmp/server.log 2>&1 &
SERVER_PID=$!
sleep 0.5
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "Server failed to start:"
cat /tmp/server.log
exit 1
fi
cleanup() {
kill $SERVER_PID 2>/dev/null || true
rm -f /tmp/auth9p-test.sock
}
trap cleanup EXIT
echo ""
echo "=== Test 1: First client establishes secret ==="
./client new | tee /tmp/client1.log
SECRET=$(grep "Use this secret" /tmp/client1.log | awk '{print $NF}')
echo "Captured secret: $SECRET"
echo ""
echo "=== Test 2: Reconnect with same secret ==="
./client "$SECRET"
echo ""
echo "=== Test 3: Wrong secret (should fail) ==="
./client wrongsecret 2>&1 || echo "(expected failure)"
echo ""
echo "=== Server log ==="
cat /tmp/server.log

View File

@ -526,11 +526,10 @@ func escapeValue(s string) string {
// BypassRequest represents a bypass request from toolsrv.
type BypassRequest struct {
ID string `json:"id"`
Cmd string `json:"cmd"`
Cwd string `json:"cwd"`
Env map[string]string `json:"env,omitempty"`
Sudo bool `json:"sudo,omitempty"`
ID string `json:"id"`
Cmd string `json:"cmd"`
Cwd string `json:"cwd"`
Env map[string]string `json:"env,omitempty"`
}
// ReadBypassPending blocks until a bypass request is available.

View File

@ -18,7 +18,6 @@ type MetaFile struct {
Scope string `json:"scope,omitempty"`
OutputFormat string `json:"outputFormat,omitempty"`
Cmd string `json:"cmd,omitempty"`
Sudo bool `json:"sudo,omitempty"`
ResetsCounter bool `json:"resetsCounter,omitempty"`
Variants []Variant `json:"variants,omitempty"`
}
@ -35,7 +34,6 @@ type Variant struct {
Scope string `json:"scope,omitempty"`
OutputFormat string `json:"outputFormat,omitempty"`
Cmd string `json:"cmd,omitempty"`
Sudo bool `json:"sudo,omitempty"`
ResetsCounter bool `json:"resetsCounter,omitempty"`
}
@ -87,9 +85,6 @@ func (m *MetaFile) Resolve() *MetaFile {
if v.Scope != "" {
resolved.Scope = v.Scope
}
if v.Sudo {
resolved.Sudo = true
}
if v.ResetsCounter {
resolved.ResetsCounter = true
}