From e09a6c12507bc285ea46ae8cc10f567774339d24 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Sat, 18 Apr 2026 20:06:43 +0200 Subject: [PATCH] elevation: add Elevator interface + superpowers backend; add elevated:true to execute_code steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove session-scoped env injection from frontend (s/{id}/env, -e/-env flags) — superseded by per-invocation superpowers run-session in the elevator. Keep agentCore env map and OLLIE_SESSION_ID propagation via pushSessionEnv. Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/core.go | 15 ++++------ pkg/elevation/elevation.go | 28 +++++++++++++++++++ pkg/elevation/superpowers.go | 44 +++++++++++++++++++++++++++++ pkg/mcp/transport.go | 22 +++++---------- pkg/tools/execute/code.go | 18 ++++++++++-- pkg/tools/execute/pipe.go | 14 ++++++++++ pkg/tools/execute/server.go | 54 +++++++++++++++++++++++++++++++----- 7 files changed, 160 insertions(+), 35 deletions(-) create mode 100644 pkg/elevation/elevation.go create mode 100644 pkg/elevation/superpowers.go diff --git a/pkg/agent/core.go b/pkg/agent/core.go index 90f9107..ef6a224 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -102,10 +102,8 @@ type AgentEnv struct { // BuildAgentEnv constructs an AgentEnv from a pre-configured Dispatcher and // optional agent config. cwd sets the working directory reported in the // system prompt; if empty, the process working directory is used. -// sessionEnv provides session-scoped values for ${VAR} expansion in MCP server -// configs, without touching the process environment. // The caller is responsible for registering all servers on d before calling this. -func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string, sessionEnv map[string]string) AgentEnv { +func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string) AgentEnv { var messages []string if cfg != nil { @@ -113,7 +111,7 @@ func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string, sessionEn if serverCfg.Disabled || serverCfg.Command == "" { continue } - transport := mcp.NewSTDIOTransport(serverCfg.Command, serverCfg.Args, serverCfg.Env, sessionEnv) + transport := mcp.NewSTDIOTransport(serverCfg.Command, serverCfg.Args, serverCfg.Env) client, err := transport.Connect() if err != nil { messages = append(messages, fmt.Sprintf("MCP %s: failed to connect: %v", name, err)) @@ -281,8 +279,7 @@ type agentCore struct { env map[string]string // session-scoped env vars } -// SetEnv stores a session-scoped variable in the core env map and propagates -// it to the execute server's subprocess environment. +// SetEnv stores a session-scoped variable and propagates it to the execute server. func (s *agentCore) SetEnv(key, value string) { s.envMu.Lock() if s.env == nil { @@ -300,8 +297,7 @@ func (s *agentCore) SetEnv(key, value string) { } } -// pushSessionEnv injects session-scoped vars into the execute server's -// subprocess environment without touching the process-global env. +// pushSessionEnv injects OLLIE_SESSION_ID into the execute server subprocess env. func (s *agentCore) pushSessionEnv() { if s.dispatcher == nil || s.sessionID == "" { return @@ -313,7 +309,6 @@ func (s *agentCore) pushSessionEnv() { } } - var _ Core = (*agentCore)(nil) // compile-time interface check // NewAgentCore creates an agentCore from the given configuration. @@ -933,7 +928,7 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve s.dispatcher.Close() } d := s.newDispatcher() - env := BuildAgentEnv(cfg, d, s.cwd, nil) + env := BuildAgentEnv(cfg, d, s.cwd) s.dispatcher = env.dispatcher s.hooks = env.Hooks s.loopcfg.systemPrompt = env.systemPrompt diff --git a/pkg/elevation/elevation.go b/pkg/elevation/elevation.go new file mode 100644 index 0000000..adcebf7 --- /dev/null +++ b/pkg/elevation/elevation.go @@ -0,0 +1,28 @@ +package elevation + +import ( + "context" + "io" + "os/exec" +) + +// Elevator runs commands with elevated privileges outside the sandbox. +// Returns the exit code; a non-zero exit code is not itself an error. +type Elevator interface { + Run(ctx context.Context, cmd, dir string, stdout, stderr io.Writer) (int, error) + Name() string +} + +// Detect returns the best available Elevator, or nil if none is configured. +func Detect() Elevator { + if sp := newSuperpowers(); sp != nil { + return sp + } + return nil +} + +// Available reports whether a named elevator binary is present in PATH. +func Available(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} diff --git a/pkg/elevation/superpowers.go b/pkg/elevation/superpowers.go new file mode 100644 index 0000000..f128386 --- /dev/null +++ b/pkg/elevation/superpowers.go @@ -0,0 +1,44 @@ +package elevation + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + + "ollie/pkg/paths" +) + +type superpowersElevator struct { + script string +} + +func newSuperpowers() *superpowersElevator { + if _, err := exec.LookPath("superpowers"); err != nil { + return nil + } + script := filepath.Join(paths.CfgDir(), "tools", "elevate_superpowers") + if _, err := os.Stat(script); err != nil { + return nil + } + return &superpowersElevator{script: script} +} + +func (s *superpowersElevator) Name() string { return "superpowers" } + +func (s *superpowersElevator) Run(ctx context.Context, cmd, dir string, stdout, stderr io.Writer) (int, error) { + c := exec.CommandContext(ctx, "superpowers", "run-session", "--", "python3", s.script, cmd) + c.Dir = dir + c.Stdout = stdout + c.Stderr = stderr + if err := c.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), nil + } + return -1, err + } + return 0, nil +} diff --git a/pkg/mcp/transport.go b/pkg/mcp/transport.go index 7c95ca7..52006f2 100644 --- a/pkg/mcp/transport.go +++ b/pkg/mcp/transport.go @@ -8,17 +8,15 @@ import ( // STDIOTransport launches a subprocess and connects a Client to its stdin/stdout. type STDIOTransport struct { - command string - args []string - env map[string]string - extraEnv map[string]string // session-scoped vars used to expand ${VAR} in env values + command string + args []string + env map[string]string } // NewSTDIOTransport creates a transport that will launch the given command. -// sessionEnv provides values for ${VAR} expansion in env values, taking -// precedence over the process environment. -func NewSTDIOTransport(command string, args []string, env map[string]string, sessionEnv map[string]string) *STDIOTransport { - return &STDIOTransport{command: command, args: args, env: env, extraEnv: sessionEnv} +// env values may contain ${VAR} references expanded against the process environment. +func NewSTDIOTransport(command string, args []string, env map[string]string) *STDIOTransport { + return &STDIOTransport{command: command, args: args, env: env} } // Connect launches the subprocess and returns a connected Client. @@ -26,14 +24,8 @@ func (t *STDIOTransport) Connect() (*Client, error) { cmd := exec.Command(t.command, t.args...) if len(t.env) > 0 { cmd.Env = os.Environ() - lookup := func(name string) string { - if v, ok := t.extraEnv[name]; ok { - return v - } - return os.Getenv(name) - } for k, v := range t.env { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, os.Expand(v, lookup))) + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, os.ExpandEnv(v))) } } diff --git a/pkg/tools/execute/code.go b/pkg/tools/execute/code.go index e452ff3..dcc3a6a 100644 --- a/pkg/tools/execute/code.go +++ b/pkg/tools/execute/code.go @@ -12,12 +12,14 @@ import ( // CodeStep is one stage in an execute_code pipeline. // Set Code/Language for inline code, Tool/Args for a named script, or Parallel for concurrent fan-out. +// Set Elevated to run the step outside the sandbox via the configured elevation backend. type CodeStep struct { Code string `json:"code"` Language string `json:"language"` Tool string `json:"tool"` Args []string `json:"args"` Parallel []CodeStep `json:"parallel"` + Elevated bool `json:"elevated"` } // resolveCodeStep loads a CodeStep into executable (code, language, trusted). @@ -178,6 +180,12 @@ func dispatchExecuteCode(ctx context.Context, e *Server, args json.RawMessage) ( // Degenerate case: single simple stage, return raw output. if len(stages) == 1 && len(stages[0].Parallel) == 0 { + if stages[0].Elevated { + e.wdMu.RLock() + dir := e.cwd + e.wdMu.RUnlock() + return e.executeElevated(ctx, stages[0].Code, dir, timeout) + } code, lang, trusted, err := resolveCodeStep(stages[0]) if err != nil { return "", err @@ -199,13 +207,17 @@ func dispatchExecuteCode(ctx context.Context, e *Server, args json.RawMessage) ( func buildExecLabel(stages []CodeStep) string { stageLabel := func(s CodeStep) string { + prefix := "" + if s.Elevated { + prefix = "[elevated] " + } if len(s.Parallel) > 0 { - return fmt.Sprintf("parallel(%d)", len(s.Parallel)) + return fmt.Sprintf("%sparallel(%d)", prefix, len(s.Parallel)) } if s.Tool != "" { - return fmt.Sprintf("tool:%s %s", s.Tool, strings.Join(s.Args, " ")) + return fmt.Sprintf("%stool:%s %s", prefix, s.Tool, strings.Join(s.Args, " ")) } - return s.Code + return prefix + s.Code } if len(stages) == 1 { return fmt.Sprintf("execute_code: %s", stageLabel(stages[0])) diff --git a/pkg/tools/execute/pipe.go b/pkg/tools/execute/pipe.go index d25bca6..c97560d 100644 --- a/pkg/tools/execute/pipe.go +++ b/pkg/tools/execute/pipe.go @@ -17,6 +17,13 @@ func (e *Server) runStage(ctx context.Context, idx int, stage CodeStep, timeout wg.Add(1) go func(j int, s CodeStep) { defer wg.Done() + if s.Elevated { + e.wdMu.RLock() + dir := e.cwd + e.wdMu.RUnlock() + results[j], errs[j] = e.executeElevated(ctx, s.Code, dir, timeout) + return + } code, lang, trusted, err := resolveCodeStep(s) if err != nil { errs[j] = err @@ -36,6 +43,13 @@ func (e *Server) runStage(ctx context.Context, idx int, stage CodeStep, timeout return out, nil } + if stage.Elevated { + e.wdMu.RLock() + dir := e.cwd + e.wdMu.RUnlock() + return e.executeElevated(ctx, stage.Code, dir, timeout) + } + if stage.Tool != "" || stage.Code != "" { code, lang, trusted, err := resolveCodeStep(stage) if err != nil { diff --git a/pkg/tools/execute/server.go b/pkg/tools/execute/server.go index f025454..519ea0d 100644 --- a/pkg/tools/execute/server.go +++ b/pkg/tools/execute/server.go @@ -14,6 +14,7 @@ import ( "time" "ollie/internal/sandbox" + "ollie/pkg/elevation" "ollie/pkg/tools" ) @@ -23,14 +24,17 @@ type Server struct { // Returns true to allow, false to deny. Trusted tools bypass this check. Confirm func(string) bool + // Elevator, if set, is used to run steps with elevated:true outside the sandbox. + Elevator elevation.Elevator + // trusted is the set of tool names that bypass Confirm. trustedMu sync.RWMutex trusted map[string]bool // cwd is the working directory for sandboxed commands. If empty, // the process working directory is used. - wdMu sync.RWMutex - cwd string + wdMu sync.RWMutex + cwd string // envExtra holds per-session environment variables injected via SetEnv. envMu sync.RWMutex @@ -43,9 +47,13 @@ type Server struct { blockedUntil time.Time } -// Decl returns a factory for an execute Server with the given working directory. -func Decl(cwd string) func() tools.Server { - return func() tools.Server { return New(cwd) } +// Decl returns a factory for an execute Server with the given working directory and elevator. +func Decl(cwd string, el elevation.Elevator) func() tools.Server { + return func() tools.Server { + s := New(cwd) + s.Elevator = el + return s + } } // ListTools implements tools.Server, returning the ToolInfo definitions for the execute_* built-in tools. @@ -88,6 +96,7 @@ Examples: "language": {"type": "string", "description": "Language interpreter (default: bash). Ignored when tool or parallel is set."}, "tool": {"type": "string", "description": "Named tool script from the tools directory. Discover available tools: grep -iA2 'keyword' $OLLIE/t/idx"}, "args": {"type": "array", "items": {"type": "string"}, "description": "Arguments for the tool script."}, + "elevated": {"type": "boolean", "description": "Run this step outside the sandbox via the elevation backend (e.g. superpowers). Only bash code is supported. Omit or false for normal sandboxed execution."}, "parallel": { "type": "array", "description": "Fan-out: steps run concurrently, outputs concatenated in submission order, result fed to next stage. All parallel steps should produce the same output schema so the concatenated result is coherent.", @@ -97,7 +106,8 @@ Examples: "code": {"type": "string"}, "language": {"type": "string"}, "tool": {"type": "string"}, - "args": {"type": "array", "items": {"type": "string"}} + "args": {"type": "array", "items": {"type": "string"}}, + "elevated": {"type": "boolean"} } } } @@ -173,6 +183,37 @@ func (e *Server) SetEnv(key, value string) { e.envMu.Unlock() } +// executeElevated runs cmd as a bash command via the configured Elevator. +// Returns (output, error); a non-zero exit code is treated as an error. +func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int) (string, error) { + if e.Elevator == nil { + return "", fmt.Errorf("elevation not available: no backend configured") + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + + var outBuf, errBuf bytes.Buffer + lw := &limitedWriter{w: &outBuf, limit: 10 * 1024 * 1024} + code, err := e.Elevator.Run(ctx, cmd, dir, lw, &errBuf) + combined := outBuf.String() + if errBuf.Len() > 0 { + combined += errBuf.String() + } + if lw.truncated { + combined += "\n[output truncated at 10MB]" + } + if ctx.Err() == context.DeadlineExceeded { + return combined, fmt.Errorf("execution timeout after %d seconds", timeout) + } + if err != nil { + return combined, fmt.Errorf("elevated execution failed: %v", err) + } + if code != 0 { + return combined, fmt.Errorf("elevated execution failed (exit %d)\nOutput: %s", code, combined) + } + return combined, nil +} + var whitespacePattern = regexp.MustCompile(`\s+`) const ( @@ -292,7 +333,6 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti cmd.Stdin = strings.NewReader(stdinData) } - // Inject per-session env vars so they're visible inside the sandbox. e.envMu.RLock() if len(e.envExtra) > 0 { cmd.Env = os.Environ()