diff --git a/README.md b/README.md index ad7978c..246a860 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ OLLIE_KIRO_TOKEN=... # bearer token or sqlite:// path (auto-detected f OLLIE_MODEL=qwen/qwen3-235b-a22b OLLIE_TOOLS_PATH=~/.config/ollie/tools # directory for tool scripts ({tool} steps) OLLIE_MEMORY_PATH=~/.config/ollie/memory # directory for memory files (ollie/m) +OLLIE_ELEVATE_SOCKET=~/.run/ollie-elevate.sock # socket path for x/elevate adapter ``` Shell environment variables take precedence over the env file. diff --git a/scripts/README.md b/scripts/README.md index bcc7636..625ac36 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -209,77 +209,52 @@ Outputs to stdout, making it composable with other pipeline stages. ## x/elevate Sandbox escape hatch invoked by `execute_code` when a step is marked `elevated: true`. -The Landlock sandbox is enforced at the kernel level — certain operations (filesystem -paths outside the sandbox config, specific syscalls) are simply blocked regardless of -what the code does. `x/elevate` provides a controlled way to run those steps outside -the sandbox entirely, without changing user privileges. +The Landlock sandbox is enforced at the kernel level — restrictions apply regardless of +user privileges within the sandboxed process. `x/elevate` is a generic client that +forwards the command to an adapter running outside the sandbox, which executes it and +returns the result. -### Interface +`x/elevate` knows nothing about the backend. It connects to the socket at +`OLLIE_ELEVATE_SOCKET`, sends `{cmd, cwd}`, and reads back `{stdout, stderr, +exit_code}`. The adapter on the other end is backend-specific. Multiple adapters can +coexist at different socket paths; switching backends is a single env var change. -`execute_code` resolves the plugin path and calls: +Set `OLLIE_ELEVATE_SOCKET` in `~/.config/ollie/env`: -```sh -x/elevate -- +``` +OLLIE_ELEVATE_SOCKET=~/.run/ollie-elevate.sock ``` -The working directory is already set to the session's `cwd` before the call. The -script must: +### Protocol -- Write combined stdout/stderr to its own stdout -- Exit with the command's exit code (non-zero is treated as failure) +`x/elevate` sends a newline-terminated JSON object and reads a JSON response: -That's the entire contract. Any script that satisfies it is a valid backend. - -The plugin is looked up in `OLLIE_PLUGINS_PATH` (first colon-separated entry) if set, -otherwise in `~/.config/ollie/scripts/x/`. Override via env to use a different location -without modifying the installed scripts. - -### The constraint - -Because Landlock is kernel-enforced, the backend cannot simply exec the command inside -the sandboxed process — the restrictions apply regardless. The command must reach a -process that was spawned outside of landrun entirely. The only viable pattern is to -forward the command to an external daemon over a socket or pipe, where that daemon runs -in an unsandboxed context and sends the result back. - -### Minimal example - -The simplest possible backend — direct exec, no sandbox. Suitable for development or -environments where the sandbox itself is not in use: - -```sh -#!/bin/sh -# x/elevate: run command outside the sandbox (no landrun in this environment). -shift # drop the '--' -exec sh -c "$1" +``` +→ {"cmd": "", "cwd": ""}\n +← {"stdout": "...", "stderr": "...", "exit_code": 0} ``` -### superpowerd example +Any process that listens on a Unix stream socket and speaks this protocol is a valid +adapter. -The default `x/elevate` uses superpowerd as its backend. superpowerd is a hypothetical -external command broker: a daemon running outside any sandbox that receives -`ExecuteCommand` requests over a Unix socket, runs them, and returns the result. This -satisfies the constraint above — the command executes in the daemon's process, which -was never wrapped by landrun. +### x/elevate-adapter-superpowerd -**How it works, step by step:** +The included adapter bridges the ollie elevate protocol to superpowerd, a hypothetical +external command broker. superpowerd is a daemon running outside any sandbox that +accepts `ExecuteCommand` requests over its own Unix socket and runs them in an +unsandboxed context — satisfying the kernel-level constraint. -1. If `SUPERPOWERD_SESSION_TOKEN` is not in the environment, the script re-execs itself - under `superpowers run-session`, which registers a session with the daemon and - re-runs the script with the token set: +**Start the adapter** (outside the sandbox, once per session): - ```python - if "SUPERPOWERD_SESSION_TOKEN" not in os.environ: - os.execvp("superpowers", ["superpowers", "run-session", "--", - sys.executable, __file__] + sys.argv[1:]) - ``` +```sh +superpowers run-session -- python3 $OLLIE/x/elevate-adapter-superpowerd +``` -2. With the token present, the script connects to the superpowerd Unix socket. The - socket path is resolved in order from `SUPERPOWERD_SOCKET`, - `SUPERPOWERD_SOCKET_DIR/superpowerd.sock`, `$XDG_RUNTIME_DIR/superpowerd/superpowerd.sock`, - or the default `/run/user//superpowerd/superpowerd.sock`. +`superpowers run-session` acquires a session token and re-runs the adapter with +`SUPERPOWERD_SESSION_TOKEN` set. The adapter then: -3. A JSON-RPC `ExecuteCommand` request is sent with the token, command string, and cwd: +1. Creates the socket at `OLLIE_ELEVATE_SOCKET` and listens for connections +2. For each request, connects to superpowerd and sends a JSON-RPC `ExecuteCommand`: ```json { @@ -295,16 +270,23 @@ was never wrapped by landrun. } ``` -4. The response's `stdout`, `stderr`, and `exit_code` are forwarded back to - `execute_code`. +3. Translates the response to the ollie protocol and returns it to `x/elevate` +4. Cleans up the socket on exit (`SIGTERM`/`SIGINT`) -### Custom backends +The superpowerd socket is resolved from `SUPERPOWERD_SOCKET`, +`SUPERPOWERD_SOCKET_DIR/superpowerd.sock`, `$XDG_RUNTIME_DIR/superpowerd/superpowerd.sock`, +or `/run/user//superpowerd/superpowerd.sock`. -Any daemon-and-socket design works. The daemon must be running outside landrun; the -`x/elevate` script is the bridge between the sandboxed agent and that daemon. +### Writing your own adapter -**Disabling elevation:** replace `x/elevate` with a script that exits non-zero. -Steps marked `elevated: true` will fail rather than escape the sandbox: +Any daemon that owns `OLLIE_ELEVATE_SOCKET` and speaks the two-message JSON protocol +is a valid adapter. The daemon must be started outside landrun; that's the only +constraint. Use `elevate-adapter-superpowerd` as a template. + +### Disabling elevation + +Replace `x/elevate` with a script that exits non-zero. Steps marked `elevated: true` +will fail rather than escape the sandbox: ```sh #!/bin/sh diff --git a/scripts/x/elevate b/scripts/x/elevate index 8bb6eac..5a6b9f9 100755 --- a/scripts/x/elevate +++ b/scripts/x/elevate @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -"""Elevation plugin: run a command outside the sandbox via superpowers. +"""Sandbox escape hatch for execute_code elevated steps. + +Connects to the socket at OLLIE_ELEVATE_SOCKET, sends the command and cwd, +and returns the result. The adapter listening on that socket is responsible +for running the command outside the Landlock sandbox. Usage: elevate -- - -Replace this script to use a different elevation mechanism. """ import json import os @@ -11,69 +13,38 @@ import socket import sys -def _socket_path(): - if p := os.environ.get("SUPERPOWERD_SOCKET"): - return p - if d := os.environ.get("SUPERPOWERD_SOCKET_DIR"): - return d + "/superpowerd.sock" - if x := os.environ.get("XDG_RUNTIME_DIR"): - return x + "/superpowerd/superpowerd.sock" - return f"/run/user/{os.getuid()}/superpowerd/superpowerd.sock" - - def main(): - if "SUPERPOWERD_SESSION_TOKEN" not in os.environ: - os.execvp("superpowers", ["superpowers", "run-session", "--", - sys.executable, __file__] + sys.argv[1:]) + sock_path = os.environ.get("OLLIE_ELEVATE_SOCKET") + if not sock_path: + print("elevate: OLLIE_ELEVATE_SOCKET not set", file=sys.stderr) + sys.exit(1) - # sys.argv: ['elevate', '--', ''] if len(sys.argv) < 3: print("usage: elevate -- ", file=sys.stderr) sys.exit(1) - cmd = sys.argv[2] - cwd = os.getcwd() - token = os.environ["SUPERPOWERD_SESSION_TOKEN"] + req = json.dumps({"cmd": sys.argv[2], "cwd": os.getcwd()}).encode() + b"\n" - sock_path = _socket_path() - sock = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: sock.connect(sock_path) except OSError as e: print(f"elevate: connect {sock_path}: {e}", file=sys.stderr) sys.exit(1) - req = json.dumps({ - "jsonrpc": "2.0", - "method": "ExecuteCommand", - "id": 1, - "params": { - "auth": {"session_token": token}, - "command": cmd, - "working_dir": cwd, - "approval_timeout": 7200, - }, - }).encode() - with sock: sock.sendall(req) - data = sock.recv(1024 * 1024) + sock.shutdown(socket.SHUT_WR) + data = b"" + while chunk := sock.recv(65536): + data += chunk resp = json.loads(data) - rpc_err = resp.get("error") - if rpc_err: - msg = rpc_err.get("message", str(rpc_err)) if isinstance(rpc_err, dict) else str(rpc_err) - print(f"elevate: {msg}", file=sys.stderr) - sys.exit(1) - - result = resp.get("result", {}) - out = result.get("stdout", "") - err = result.get("stderr", "") - if out: - sys.stdout.write(out) - if err: - sys.stderr.write(err) - sys.exit(result.get("exit_code", 0)) + if resp.get("stdout"): + sys.stdout.write(resp["stdout"]) + if resp.get("stderr"): + sys.stderr.write(resp["stderr"]) + sys.exit(resp.get("exit_code", 0)) if __name__ == "__main__": diff --git a/scripts/x/elevate-adapter-superpowerd b/scripts/x/elevate-adapter-superpowerd new file mode 100755 index 0000000..e05a102 --- /dev/null +++ b/scripts/x/elevate-adapter-superpowerd @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Elevation adapter: bridges the ollie elevate protocol to superpowerd. + +Creates a Unix socket at OLLIE_ELEVATE_SOCKET, accepts {cmd, cwd} requests +from x/elevate, and forwards them to superpowerd via its ExecuteCommand +JSON-RPC interface. Run this outside the Landlock sandbox. + +Usage: + superpowers run-session -- python3 elevate-adapter-superpowerd + +Environment: + OLLIE_ELEVATE_SOCKET path for the listening socket (required) + SUPERPOWERD_SESSION_TOKEN set automatically by superpowers run-session + SUPERPOWERD_SOCKET override superpowerd socket path + SUPERPOWERD_SOCKET_DIR directory containing superpowerd.sock +""" +import json +import os +import signal +import socket +import sys +import threading + + +def sp_socket_path(): + if p := os.environ.get("SUPERPOWERD_SOCKET"): + return p + if d := os.environ.get("SUPERPOWERD_SOCKET_DIR"): + return d + "/superpowerd.sock" + if x := os.environ.get("XDG_RUNTIME_DIR"): + return x + "/superpowerd/superpowerd.sock" + return f"/run/user/{os.getuid()}/superpowerd/superpowerd.sock" + + +def handle(conn): + with conn: + data = b"" + while chunk := conn.recv(65536): + data += chunk + if b"\n" in data: + break + + try: + req = json.loads(data) + except json.JSONDecodeError as e: + resp = {"stdout": "", "stderr": f"elevate-adapter: bad request: {e}", "exit_code": 1} + conn.sendall(json.dumps(resp).encode()) + return + + token = os.environ.get("SUPERPOWERD_SESSION_TOKEN", "") + sp_path = sp_socket_path() + + sp = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) + try: + sp.connect(sp_path) + except OSError as e: + resp = {"stdout": "", "stderr": f"elevate-adapter: connect {sp_path}: {e}", "exit_code": 1} + conn.sendall(json.dumps(resp).encode()) + return + + sp_req = json.dumps({ + "jsonrpc": "2.0", + "method": "ExecuteCommand", + "id": 1, + "params": { + "auth": {"session_token": token}, + "command": req["cmd"], + "working_dir": req["cwd"], + "approval_timeout": 7200, + }, + }).encode() + + with sp: + sp.sendall(sp_req) + sp_data = sp.recv(1024 * 1024) + + sp_resp = json.loads(sp_data) + rpc_err = sp_resp.get("error") + if rpc_err: + msg = rpc_err.get("message", str(rpc_err)) if isinstance(rpc_err, dict) else str(rpc_err) + resp = {"stdout": "", "stderr": f"elevate-adapter: {msg}", "exit_code": 1} + else: + result = sp_resp.get("result", {}) + resp = { + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", ""), + "exit_code": result.get("exit_code", 0), + } + + conn.sendall(json.dumps(resp).encode()) + + +def main(): + if "SUPERPOWERD_SESSION_TOKEN" not in os.environ: + os.execvp("superpowers", ["superpowers", "run-session", "--", + sys.executable, __file__] + sys.argv[1:]) + + sock_path = os.environ.get("OLLIE_ELEVATE_SOCKET") + if not sock_path: + print("elevate-adapter-superpowerd: OLLIE_ELEVATE_SOCKET not set", file=sys.stderr) + sys.exit(1) + + sock_path = os.path.expanduser(sock_path) + os.makedirs(os.path.dirname(sock_path), exist_ok=True) + + if os.path.exists(sock_path): + os.unlink(sock_path) + + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(sock_path) + srv.listen() + + def shutdown(sig, frame): + srv.close() + if os.path.exists(sock_path): + os.unlink(sock_path) + sys.exit(0) + + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + + print(f"elevate-adapter-superpowerd: listening on {sock_path}", file=sys.stderr) + + while True: + try: + conn, _ = srv.accept() + except OSError: + break + threading.Thread(target=handle, args=(conn,), daemon=True).start() + + +if __name__ == "__main__": + main()