Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
98793db
feat(plan): structured composite-execution plans (retire the bash -lc…
cuihtlauac Oct 16, 2026
43c8ecd
feat(plan): add `alt` op (shell ||) — run steps until one succeeds
cuihtlauac Oct 17, 2026
0cb0139
chore: git-ignore CLAUDE.local.md
cuihtlauac Oct 17, 2026
711e9fb
fix(plan): flatten run-as `who` so the documented `as` wire form parses
cuihtlauac Oct 19, 2026
0ad3afd
feat(sudo-request): --plan/--plan-file to drive structured plans like…
cuihtlauac Oct 19, 2026
813ca7b
refactor(plan)!: rename variants + add a `;` (Seq) op
cuihtlauac Oct 19, 2026
94229c8
docs: contrast sudo-proxy's security model with agent harnesses
cuihtlauac Oct 19, 2026
1118a43
docs(backlog): capture the shell-escape-hatch design goal + remaining…
cuihtlauac Oct 19, 2026
d1ae693
feat(tui): consolidated deduped resolve block + colorized run-as tags
cuihtlauac Oct 19, 2026
f4fbdb8
feat(daemon): --log-json to log each request's plan as pretty JSON
cuihtlauac Oct 19, 2026
3fe86cb
feat(tui): copy-pasteable Command line + numbered Steps block + escal…
cuihtlauac Oct 19, 2026
369b9fc
fix(tui): Command line threads ambient sudo for a privileged request
cuihtlauac Oct 19, 2026
3c31819
docs(backlog): fix stale op name in the retry-hazard caveat
cuihtlauac Oct 19, 2026
d7e3d1a
docs(executor): explain why pipe stages setsid, and daemon-as-shell-e…
cuihtlauac Oct 19, 2026
83de309
test(manual): real-log plan corpus + UI checks for the approval prompt
cuihtlauac Nov 9, 2026
83036d3
feat(tui): Steps and Command share one layout that groups like the pl…
cuihtlauac Nov 9, 2026
b56e969
fix(tui): stop the [y/N] blink once the prompt is answered or times out
cuihtlauac Nov 9, 2026
428b9cc
docs(assurance): record failed-cd divergence as a G4.5 residual
cuihtlauac Nov 9, 2026
83b99f7
feat(tui): scopes render as ( cd D && ( body ) ) so a failed cd skips…
cuihtlauac Nov 9, 2026
dcd8723
fix(plan): make the approved display match execution, gate run-as pla…
cuihtlauac Nov 9, 2026
66a9881
docs(backlog): consolidate plan follow-ups; log the PR #59 review fixes
cuihtlauac Nov 10, 2026
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
149 changes: 146 additions & 3 deletions backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,63 @@ the assurance ladder defined in
[docs/formalisation-roadmap.md](docs/formalisation-roadmap.md); each names its
rung.

## Priority #1 — plan-model follow-ups (consolidated)

The next work, pulled to the front. These surfaced while building and
live-testing the structured-plan prompt rework this session; detailed write-ups
remain in the sections further down, linked here. Tackle in roughly this order:

1. **Close the `exec sh -c` loophole** (highest payoff). `validate_tree` should
treat an `Exec`/pipe-stage whose `argv[0]` resolves to a shell
(`sh`/`bash`/`dash`/`zsh`/…) with `-c` exactly like a `Shell` node — same
opaque flag at the prompt, same strict-mode fate. Today a plain `exec sh -c
'…'` smuggles a shell onto the path *without* the opaque marker, quietly
defeating the "make `Shell` avoidable" goal. See
*"Make the shell escape hatch avoidable"* below.
2. **Strict policy mode that rejects the shell hatch.** `protocol.rs` already
promises "a strict policy mode rejects it," but none exists. Add a per-daemon
`policy.no_shell` / `--no-shell` that rejects any `Shell` node (and, with #1,
any `exec sh -c`) before prompting. Same section.
3. **Shell-tail ops: `detach` and read-file.** A `detach` op (backgrounding +
structured pid, replacing `nohup … & echo $!` — campaign observation **O1**;
the only gap that still forced the `system` hatch live) and a read-file op
(replacing `$(cat /proc/…)`). See the Dune-gaps list in *"Structured
composite-execution plan"* and lever 3 of the shell-hatch section.
4. **`sudo-request --print` exit code (O2).** With `--print` the client exits `0`
even on an error `Response` (it prints the result); without `--print` an error
exits `1`. Make `--print` propagate a non-zero exit on `Status::Error` /
`Denied` / `Timeout` (or document the discrepancy). Found during the test
campaign; had no backlog home until now.
5. **Treat whole-command `privileged` as sugar for an ambient `run-as root`.**
It already *is* semantically (`compile_plan` seeds ambient root from the flag,
≡ a `run-as root` wrapping the whole plan). Steer callers toward per-stage
`run-as` so pipes stay unprivileged and dodge the sudo-credential-in-pipe
caveat (the blunt flag escalates *every* leaf, including pipe stages → the
caveat bites). Consider deprecating the flag once the plan path is primary;
keep it meanwhile for flat-`pipeline` wire-compat. (Raised in discussion this
session; not previously recorded.)
6. **Soften the sudo-credential-in-pipe caveat + partial-`run` retry hazard.**
See *"Partial-`run` retry hazard and the sudo-credential-in-pipe caveat"* for
the mitigation menu (auth-probe before stateful stages, surface the partial
boundary so callers resume rather than replay, advisory non-idempotency flag).
7. **`env` hides the real program from `Resolves:`.** A step written as
`env VAR=… opam exec … dune …` resolves only `env`; `opam`/`dune`, the
programs that actually run, never appear in the resolve block. It arises
because `setenv` only accepts allowlisted names (`TSAN_OPTIONS`,
`OCAMLRUNPARAM`, … are rejected), so callers fall back to `env` (corpus cases
06, 22). Fix options: resolve the program after `env`'s `VAR=…` / option
arguments (like a wrapper-aware resolver, and flag an unresolvable one), or
allow non-allowlisted names in `setenv` for non-root subtrees so the `env`
wrapper is unnecessary. Found live-testing case 22.
8. **`--pkexec` with a multi-step `privileged: true` plan.** Each escalated
step raises its own pkexec dialog, which shows only that step (and, since
steps run as `pkexec env -C <dir> -- argv`, names `/usr/bin/env` as the
program). With the `pkexec-cache` polkit rule, steps after the first run with
no dialog at all. Options: reject multi-leaf plans in `--pkexec` mode, or
show the whole plan in the TUI and use pkexec only to authenticate. Plans
that escalate only via `run-as` already take the TUI prompt (PR #59 review
fix). Found in the PR #59 review.

## Cap far-future request timestamps, then re-climb the ladder to Rung 4

The replay-protection residual surfaced **during PR #35** by the Extended Rung 4
Expand Down Expand Up @@ -164,10 +221,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 `run`) 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 +245,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 +316,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
22 changes: 22 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@
Most recent at top. See [docs/formalisation-roadmap.md](docs/formalisation-roadmap.md)
for the assurance-ladder context behind the security-formalisation entries.

## 2026-11-09 — PR #59 review: the displayed plan now means what runs

A review of the structured-plan PR found plans whose prompt text meant
something other than what the executor ran, and a `--pkexec` approval gap.
Fixed in `dcd8723`:

- **`--pkexec` gate.** Only `privileged: true` requests go to pkexec. A plan
that escalates only through `run-as` takes the TUI prompt and sudo, so its
unescalated steps can no longer run unapproved.
- **`with-accepted-exit-codes` is shown** (was backlog Priority #1 item 8). It
renders as `{ cmd || [ $? -eq N ]; }` (a `case` for several codes) in both
`Steps:` and `Command:`, with an `# exit N accepted` comment in Steps, and
both G4.5 property tests now generate it.
- **Validation rejects shapes with no faithful shell spelling:** a stream
redirected twice along one path, `with-stdin-from` on a later pipe stage,
relative `chdir`/redirect/stdin paths, and env keys that are not shell
variable names.
- **`run-as` user `root`** is normalised to root; the sudo `#uid` form is refused.
- **`--log-json`** escapes control and bidi characters before printing.
- **pkexec cwd.** Escalated leaves run as `pkexec env -C <dir> -- argv`, since
pkexec otherwise starts in the target user's home.

## 2026-10-16 — Alert the human when an approval prompt appears (bell + blink)

Usability/safety work, not an assurance-ladder rung — the change is purely
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
2 changes: 2 additions & 0 deletions docs/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ a sanitized environment.*
| **Sn4.3** | After **F3** fix: displayed command length is bounded with an explicit hidden-bytes marker. | 1 | [discharged] |
| **G4.4** | Residual: printable-range Unicode look-alikes / NBSP in `reason` (passive, cannot hide the real command). | — | |
| **Sn4.4** | Documented residual in [security-audit.md](security-audit.md) (F1). | 0 | [accepted-risk] |
| **G4.5** | The grouping shown at the gate is the grouping that runs: the `Steps:` block (what the operator reviews) and the `Command:` line (what they may paste) parse, as shell, to the same tree, and that tree means what the executor runs. | — | |
| **Sn4.5** | `src/tui.rs`: both are printed from one layout tree (the rules in [testing.md](testing.md#how-the-prompt-shows-grouping)); golden layouts; `prop_steps_and_command_parse_to_the_same_tree` (bash parse trees equal, 300 random plans incl. `run-as`, redirections, quoting); `prop_displayed_text_runs_like_the_executor` (executor stdout = `sh` on each text, 150 random side-effect-free plans); `tests/manual_corpus.rs` (bash parse check on every manual plan). `with-accepted-exit-codes` renders as `{ cmd || [ $? -eq N ]; }` and is in both properties; shapes with no faithful shell spelling (a stream redirected twice, `with-stdin-from` on a later pipe stage, relative paths, env keys that are not shell names) are rejected by `ValidatedRequest::validate`. Residuals: the differential run covers only privilege-free, side-effect-free plans; `bash` is the oracle, not a specification; a pasted line runs under the operator's environment, not the executor's sanitized one; after a failed `cd` the two still diverge on what follows the scope: the executor aborts the whole plan (the spawn fails), while a shell running the pasted line skips the scope's body (`( cd DIR && ( body ) )`, so it never runs in the previous directory) but goes on with any `;`-sequenced steps after it. | 2 | [partial] → see [formalisation-roadmap.md](formalisation-roadmap.md#display-fidelity-across-the-rungs) |

### G5 — Approval is necessary and binding

Expand Down
Loading
Loading