Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/target
/scratch
/CLAUDE.local.md
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ relaxed if you deliberately mark a host eligible in `hosts.json` *and* confirm
once per session (a grant that is never persisted). See
[REVIEWING.md](REVIEWING.md) invariant **G7**.

**Escalate vs. confine.** sudo-proxy's security model is the inverse of an agent
harness's. A harness (Claude Code, Codex CLI, …) gates with *software* —
permission rules plus an optional classifier — and runs the agent at *your own*
privilege, narrowing it to a safe subset. sudo-proxy inverts both: a *human*
approves every command (no classifier, no auto-approve) and execution is
*escalated* beyond what the agent has. The two are complementary — the harness
bounds the agent to your privileges; sudo-proxy is how it reaches past them, with
a human as the escalation authority. See
[docs/architecture.md](docs/architecture.md#security-model-vs-agent-harnesses).

For how this relates to mcp-firewall, sandboxing, polkit, doas, and other
neighboring tools, see [docs/comparison.md](docs/comparison.md).

Expand Down
92 changes: 89 additions & 3 deletions backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,11 @@ on top:**
requests `3a7cb1f9`, `e6aeb649`, `942cd83f`, `b1f6294a`). Dune actions are
synchronous; `concurrent` still joins. We need launch-and-detach returning the
pid as a structured field (not a shell `$!`).
- **`||` fallback.** `progn` ≈ `;`/`&&` (stops on failure); there is no
run-on-failure branch for the log's `pgrep … || echo none`. Add it, or fold it
- ~~**`||` fallback.**~~ *Landed:* the `alt` op (dual of `seq`) runs steps until
one succeeds, covering `pgrep … || echo none`. A first-class op, not a fold
into `with-accepted-exit-codes`.
- **Append redirection (`>>`).** `with-stdout-to` truncates; no append mode.
- ~~**Append redirection (`>>`).**~~ *Landed:* `redirect` takes `mode:"append"`
alongside `"truncate"` (and `stream:"both"` for `2>&1`).
- **Runtime substitution** (`$(cat /proc/…)`, `$!`, `$?`). Dune resolves
`%{…}` at rule-eval, not runtime — so it has no runtime `$(…)` either, which
*confirms* our plan: promote the common cases to first-class ops (a read-file op
Expand All @@ -187,6 +188,49 @@ shell`) and a policy mode rejects it — making `no-opaque-blob` a special case
daemon-side *reject-unresolvable-stages*, enforced structurally and for every
client rather than by regex in one user's hook.

## Make the shell escape hatch avoidable (plan-model design goal + remaining levers)

**The design goal.** The `Plan` type's *raison d'être* is to make its `Shell`
constructor (the opaque escape hatch, formerly `System`) unnecessary: every
structured op exists so a given shell feature need not be expressed by shelling
out. The real invariant is **no shell on the privileged path**; `Shell` is the
one node that breaks displayed-equals-executed — it resolves only the shell
binary, so its inner commands are invisible at the prompt. The goal is therefore
not to *delete* `Shell` (the shell tail — runtime `$(…)`, `$!`, globbing,
word-splitting, profile functions — is open-ended) but to **shrink-and-flag**:
drive real usage into structured ops, and keep every remaining shell use loud,
countable, and policy-rejectable. Read each `Shell` node as measurable debt — a
prompt to ask "what op would have avoided this?" That loop already produced `alt`
(for `||`) and `seq` (for `;`). See
[docs/architecture.md](docs/architecture.md) ("Security model vs. agent
harnesses") for the framing.

**Remaining levers** (rough payoff order):

1. **Close the `exec sh -c` loophole** (highest payoff). The goal is quietly
defeated today not by `Shell` but by its dishonest sibling: a plain `Exec`
leaf (or pipe stage) `["sh","-c","<script>"]` puts a full shell on the path
**without** the opaque flag — it renders as a tame `exec` stage with
`/usr/bin/sh` resolved while a whole script hides in one argv element. (Seen
in the live campaign: C1/C2 used `sh -c "…;…"` leaves to sequence what the
type couldn't yet express.) Fix: have `validate_tree` recognize an
`Exec`/pipe-stage whose `argv[0]` is a shell (`sh`/`bash`/`dash`/`zsh`/…) with
a `-c` and treat it exactly like a `Shell` node — same opaque flag at the
prompt, same strict-mode fate. Without this, every other lever is bypassable.
2. **Implement the documented strict policy mode.** `protocol.rs` already promises
"a strict policy mode rejects it," but no such mode exists. Add a per-daemon
policy (`hosts.json` `policy.no_shell` and/or a `--no-shell` flag) that
**rejects** any `Shell` node — and, with lever 1, any `exec sh -c` — before
prompting: "structured or nothing" for operators who want it. Folds into the
*reject-unresolvable-stages* idea in the structured-plan section above.
3. **Catalogue the shell tail as first-class op proposals.** The two most common
forces in the log.1 trail that still require the hatch: a `detach` op
(backgrounding + a structured pid return, replacing `nohup … & echo $!`) and a
read-file op (replacing `$(cat /proc/…)`). See the "Gaps Dune does NOT cover"
list above. Each retired force converts an opaque stage into reviewable ones.

Captured follow-up, not active work.

## Move the `no-opaque-blob` floor into the daemon (interim, ahead of the plan model)

**Why.** `no-opaque-blob` is currently a Claude Code PreToolUse hook (matcher
Expand Down Expand Up @@ -215,6 +259,48 @@ daemon failing open must never be the difference between safe and unsafe; the
hook failing open (misconfig, different client) likewise must not — the daemon is
the backstop. Captured follow-up, not active work.

## Partial-`run` retry hazard and the sudo-credential-in-pipe caveat

Surfaced while reviewing the structured-plan executor (the plan model above,
first increment landed). Two interacting behaviours deserve a safer story than
"the caller must know."

**The retry hazard.** A `run` (≈ `&&`) runs its steps in order and stops at the
first non-accepted failure (`executor.rs`, `run_seq` → `SeqNode::Run`). When a later
step fails, the earlier steps' side effects are **already committed**. The
response marks which stages ran (per-stage exit codes; a skipped stage carries
the `-1` not-run sentinel), but nothing stops a caller from resubmitting the
*same* plan — which replays the already-committed early steps. For
non-idempotent effects (`mkdir`, append-redirects, counters, `git commit`) that
double-applies or errors on the second pass. This is the classic
partial-failure/retry footgun of any `set -e` script; the plan model makes the
partial state *visible* but does not prevent the replay.

**The sudo-credential compounding.** The most likely "later step fails" cause is
a privileged stage with no cached sudo credential. A **single** privileged stage
runs with `setpgid` + the foreground-tty guard and *can* prompt; a privileged
stage **inside a pipe** runs with `setsid` (detached, no controlling tty) and
**cannot** prompt — it relies on a cached credential. Whether a leading
`sudo true`-style prime carries into a `setsid` pipe child depends on the host's
sudoers `timestamp_type` (`global` → yes; the common `tty` default → likely
not). So a pipe whose privileged stage is neither NOPASSWD nor already-cached
fails fast with "no tty present," and credential-priming is not a reliable fix
*inside a pipe*.

**Mitigations available today** (caller-side): order privileged stages first;
make early stages idempotent (`mkdir -p`, truncate not append); rely on
NOPASSWD / cached creds; prefer a single privileged stage over `sudo`-inside-a-
pipe; and use the per-stage exit-code array to resubmit only the not-run stages
rather than replaying the whole plan.

**Potential improvements to weigh** (none a full fix — idempotency stays the
caller's responsibility): (1) surface the partial-execution boundary
prominently in the response/prompt so a caller resumes rather than replays;
(2) an optional executor-inserted auth-probe for privileged plans that fails
fast *before* any stateful stage, documenting the `timestamp_type` caveat;
(3) advisory flagging of obviously-non-idempotent leading stages. Captured
follow-up, not active work.

---

## Rung 6 — seL4-style end-to-end refinement (aspirational)
Expand Down
53 changes: 53 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,59 @@

[← README](../README.md)

## Security model vs. agent harnesses

sudo-proxy and the permission model of an agent CLI harness (Claude Code, Codex
CLI, and similar) differ on two axes. Getting these straight explains why
sudo-proxy leans so hard on the displayed-equals-executed guarantee.

**1. The gatekeeper (human vs. software).** Both are *mediated*: the model never
executes anything itself — it only emits a *request* that something else decides
on and runs. The difference is what sits in the gate:

- A **harness** gates with **software**: configured permission rules
(allow / ask / deny), optionally a safety classifier, and a human prompt only
on the "ask" path. The designed-for case is **automated** — an allowlisted
command runs with no human in the loop.
- **sudo-proxy** gates with a **mandatory human keypress** on `/dev/tty` for
*every* command, fail-closed (default deny, 60 s timeout). There is **no
classifier and no auto-approve** for privileged commands; software only
*rejects* (dangerous characters, structural invalidity, replay/freshness) — it
never *approves*. The human is the sole approving authority.

**2. Privilege direction (escalate vs. confine).** This is the deeper difference
in assumption:

- A **harness** performs capability *restriction*. The agent already runs **as
you**, with your ambient privileges; the harness *narrows* that to a safe
subset. Its executor (the Bash tool) runs **unprivileged** — no escalation.
- **sudo-proxy** performs capability *escalation*. The client/agent has **no
privilege of its own**; sudo-proxy *grants* one it otherwise lacks —
`sudo` / `pkexec` to **root or another user** — and binds each grant to a
human who read the exact command.

| | Agent harness (Claude Code, Codex CLI) | sudo-proxy |
|---|---|---|
| Model's role | emits a tool request | emits a request (MCP / socket client) |
| Gate | permission rules + optional classifier + optional human | **mandatory human** per command; no classifier |
| Default decision | can be fully automated | never automated (always a keypress) |
| Executor privilege | your own (ambient) | **escalated** (root / other user) |
| Purpose | **confine** an agent that runs as you | **escalate** beyond what the agent has |
| Failure mode | depends on policy | fail-closed to a human (default deny) |

Both start from the same premise — the model's output is an *untrusted request*,
not an authorization — and answer "who authorizes it?" differently: **software
policy** (harness) vs. **a human reading the literal command** (sudo-proxy).
Because the authorizer is a human rather than a classifier, sudo-proxy's entire
security reduces to *what that human can see and comprehend at the prompt* —
hence invariants **G1** (nothing privileged runs without a human approving the
exact command shown) and the per-stage resolved-binary rendering
([REVIEWING.md](../REVIEWING.md)).

The two are **complementary, not competing**: the harness confines the agent to
your privileges; sudo-proxy is how the agent reaches *past* them for a rare
privileged action — with a human, not a classifier, as the escalation authority.

## Project structure

```
Expand Down
99 changes: 99 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Manual testing: drive the daemon live

[← README](../README.md) · see also [usage.md](usage.md), [protocol.md](protocol.md)

The unit and integration suites (`cargo test`) cover logic and drive the daemon
through a *scripted* approval. This doc is the complement: how to exercise a new
feature **end-to-end through the real daemon and the real human gate** — the
approval prompt appears in your terminal and you approve it yourself — before
merging. It is the fastest way to see a change behave exactly as an operator (or
the MCP `execute` tool) will experience it.

The key enabler is `sudo-request --plan`, which assembles a structured `Plan`
request the same way the MCP tool does (`mcp::build_request`), so you can drive
the full plan surface (`exec`, `run` (`&&`), `seq` (`;`), `alt` (`||`), `pipe`,
`redirect`, `run-as`, `setenv`, `chdir`, `shell`) from the command line — no MCP client and no hand-written
socket JSON required.

## Setup

Build the branch under test, then run the daemon in one terminal and send
requests from another.

**Terminal A — the daemon + approval TUI.** This is where the prompt appears and
where you press the keys. Use a dedicated test socket so you never collide with
an installed daemon's default socket:

```bash
cargo build --release
./target/release/sudo-proxy -v --socket /tmp/sp-test.sock
```

**Terminal B — send requests** with the freshly built client:

```bash
# A single command / pipeline (flat form)
./target/release/sudo-request --socket /tmp/sp-test.sock --no-privilege --print \
echo hi '|' wc -c

# A structured plan — the same assembly the MCP execute tool performs
./target/release/sudo-request --socket /tmp/sp-test.sock --no-privilege --print \
--reason "try alt" \
--plan '{"op":"alt","steps":[{"op":"exec","argv":["false"]},{"op":"exec","argv":["echo","fallback-ran"]}]}'
```

When the request arrives, Terminal A shows the per-stage prompt — each stage with
its own resolved real binary and run-as tag — and waits for a key. Press **`y`**
to approve; the client in Terminal B then prints the result.

For a large plan, keep it in a file and use `--plan-file` (or `--plan-file -` to
read from stdin):

```bash
./target/release/sudo-request --socket /tmp/sp-test.sock --no-privilege --print \
--plan-file ./scratch/plan.json
```

## Reading the result

With `--print`, the client shows the structured response:

```
exit [1, 0]
== stdout == (1 lines)
fallback-ran
```

- The `exit [...]` array is one entry **per stage, in prompt order**.
- A stage that was **not run** (short-circuited) reports the `-1` sentinel. For
the `alt` above with a succeeding first step you would instead see
`exit [0, -1]` — the first alternative succeeded, so the second never ran.
- `stdout` is the aggregate of every captured stage.

## Tips

- **Decline** with `n` or Enter (default is deny) → the client prints
`Request denied.` and exits non-zero. Good for testing the deny path.
- **Mimic the MCP session label** with `--session sudo-proxy-mcp` if a feature
keys off the session name.
- **Exercise privileged stages** by dropping `--no-privilege` (you will satisfy a
real `sudo`/`pkexec` prompt) or by using a `run-as` node inside the plan.
- **Fidelity caveat.** `sudo-request` reproduces the MCP's *request assembly*, not
its policy layer: the MCP additionally refuses unprivileged *local* commands
(invariant G7 — "use the Bash tool"). That refusal lives in the MCP execute
handler, not in the daemon, which is exactly what lets you drive unprivileged
local plans here for testing.

## Without a human (CI / headless)

The human gate reads `/dev/tty`, so full execution needs either a person or a
pseudo-terminal harness. Two headless-friendly paths:

- **Pre-prompt rejection needs no keypress.** A structurally invalid plan (e.g. a
`redirect` wrapping a `seq`/`alt`) is rejected *before* prompting, so a client
call returns an `invalid plan: …` error with no interaction — handy for
smoke-testing the client→daemon plumbing against a backgrounded daemon.
- **Scripted approval** for real execution: the integration tests drive the
daemon through a scripted prompter — see `tests/plan_exec.rs`
(`plan_request_end_to_end_through_daemon`) and `tests/validation.rs`. Prefer
adding a case there over automating the live TUI.
12 changes: 12 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,20 @@ sudo-request --no-privilege ls /etc

# Tag the request with a session name (default: sudo-request-cli)
sudo-request --session my-project apt update

# Send a structured plan (assembled like the MCP execute tool). Drives the full
# plan surface — exec / run(&&) / seq(;) / alt(||) / pipe / redirect / run-as /
# setenv / chdir / shell.
sudo-request --no-privilege \
--plan '{"op":"alt","steps":[{"op":"exec","argv":["false"]},{"op":"exec","argv":["echo","none"]}]}'

# ...or read the plan JSON from a file ('-' for stdin)
sudo-request --no-privilege --plan-file ./scratch/plan.json
```

To drive a new feature end-to-end through the real daemon and human gate before
merging, see [testing.md](testing.md).

## Remote hosts over SSH

`sudo-proxy --host HOST` resolves the remote UID (cached in
Expand Down
9 changes: 8 additions & 1 deletion src/bin/sudo-proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ struct Opts {
login: Option<String>,
pkexec: bool,
verbose: bool,
/// Log each request's command (the `plan`) as pretty-printed JSON to stderr.
log_json: bool,
/// `None` = take the default from `hosts.json` (`policy.unattended_eligible`).
/// `Some(_)` = an explicit CLI flag was passed and overrides the file.
unattended_eligible: Option<bool>,
Expand Down Expand Up @@ -91,6 +93,7 @@ fn main() {
mode,
pkexec_only: opts.pkexec,
verbose: opts.verbose,
log_json: opts.log_json,
unattended_eligible,
..Default::default()
};
Expand Down Expand Up @@ -132,6 +135,7 @@ fn parse_args(args: &[String]) -> Opts {
let mut login = None;
let mut pkexec = false;
let mut verbose = false;
let mut log_json = false;
// Tri-state: None means "no CLI flag passed, use hosts.json policy",
// Some(_) means an explicit flag overrides the file.
let mut unattended_eligible: Option<bool> = None;
Expand All @@ -153,12 +157,13 @@ fn parse_args(args: &[String]) -> Opts {
}
"--pkexec" => pkexec = true,
"--verbose" | "-v" => verbose = true,
"--log-json" => log_json = true,
"--unattended-eligible" => unattended_eligible = Some(true),
"--no-unattended-eligible" => unattended_eligible = Some(false),
"--forward-agent" => forward_agent = true,
"--version" | "-V" => sudo_proxy::cli::print_version("sudo-proxy"),
"--help" | "-h" => {
eprintln!("Usage: sudo-proxy [--socket PATH] [--host HOST] [--pkexec] [-v] [--unattended-eligible] [--forward-agent]");
eprintln!("Usage: sudo-proxy [--socket PATH] [--host HOST] [--pkexec] [-v] [--log-json] [--unattended-eligible] [--forward-agent]");
eprintln!();
eprintln!("Privileged command execution proxy.");
eprintln!("Listens on a Unix socket for JSON requests and executes them via pkexec or sudo.");
Expand All @@ -169,6 +174,7 @@ fn parse_args(args: &[String]) -> Opts {
eprintln!(" --host HOST Connect to remote host via SSH tunnel");
eprintln!(" --pkexec Use pkexec directly (no TUI prompt, pkexec handles both auth and approval)");
eprintln!(" --verbose, -v Print startup info and log each request to stderr");
eprintln!(" --log-json Log each request's plan as pretty-printed JSON to stderr");
eprintln!(" --unattended-eligible Allow a human to grant unattended unprivileged execution");
eprintln!(" for this session via the prompt's `a` answer (overrides hosts.json;");
eprintln!(" the grant is in-memory only and never persisted)");
Expand All @@ -188,6 +194,7 @@ fn parse_args(args: &[String]) -> Opts {
login,
pkexec,
verbose,
log_json,
unattended_eligible,
forward_agent,
}
Expand Down
Loading
Loading