Skip to content

feat(plan): structured composite-execution plans (retire the bash -lc wrapper) - #59

Open
cuihtlauac wants to merge 14 commits into
mainfrom
feat/structured-plan
Open

cuihtlauac wants to merge 14 commits into
mainfrom
feat/structured-plan

Conversation

@cuihtlauac

@cuihtlauac cuihtlauac commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

What & why

A captured approval trail showed every command arriving as argv = ["bash","-lc","<script>"]. That hollows out the gate two ways: the Resolves: line is always /usr/bin/bash (the real binaries are chosen by $PATH inside the script), and one root approval authorizes a whole bundle of effects hidden in a wall of text.

This generalizes a request's flat pipeline into a Plan tree the daemon executes directly — fork/exec per stage with pipes, redirections, env, per-stage run-as, and sequencing — with no shell on the privileged path. The load-bearing invariant: the structure the human approves is literally the structure that runs.

Implements backlog item "Structured composite-execution plan" (first increment). Rebased onto main (now includes the bell/blink work from #58); one commit.

Highlights

  • Schema (src/protocol.rs): JSON-native op-tagged Plan enum — run, pipe, seq, redirect (incl. append + 2>&1), with-stdin-from, setenv, chdir, run-as ([ROOT]/[as user]), with-accepted-exit-codes, and an explicit system opaque escape hatch. Recursive validate_tree reuses the existing has_dangerous_chars gate on every leaf + depth/node bounds, inside the one ValidatedRequest::validate constructor (typestate preserved). A flat pipeline desugars into a Plan, so existing clients are unaffected; effective_privileged = privileged || plan-contains-run-as closes the unprivileged-smuggling-root gap.
  • Executor (src/executor.rs): two passes (pure compile → leaf list + sequencing tree; run → pipe groups, seq short-circuit, one plan-wide deadline). Escalated leaves spawn as sudo [-u user] -- <literal argv> / pkexec [--user u] argv, never sudo sh -c. pipeline_to_shell and the pkexec sh -c branch are deleted. setenv is held to the env allowlist so it can't become a bypass.
  • Prompt (src/tui.rs): every stage renders its run-as tag and its own resolved binary (fixing the vacuous single /usr/bin/bash line); system stages flagged opaque; a stage-count cap keeps [y/N] on-screen. Simple single commands keep the classic one-line form.
  • Wiring (src/server.rs, src/mcp.rs): dispatch keys off effective privilege; structurally-invalid plans are rejected before prompting; audit logs are plan-aware; the MCP execute tool gained a plan parameter, documented with the actionable seq-short-circuit and sudo-in-pipe caveats.

Design decisions

  • Per-leaf sudo (not a root daemon). The daemon stays same-uid; escalation is per-stage sudo/pkexec with literal argv. Removes the shell from the privileged path with no deployment or threat-model change. (A root-daemon + direct-setuid model is a future, separately-gated milestone.)
  • One documented deviation: pipe stages keep setsid (a pipelined child can't interactively prompt, so it detaches and relies on cached credentials — matching prior behavior); a single escalated leaf uses setpgid + the foreground guard and can prompt. The sudo-credential-in-pipe consequence is documented in the code and backlog.

Tests

Rung-2 properties (render-vs-execute structural identity over random plans, compile totality, a structural no-shell-on-privileged-path check), a Kani leaf harness, tests/plan_exec.rs (incl. end-to-end through the real daemon), and wire-level plan validation tests. Full lib suite + integration green; strict clippy -D warnings clean; release build clean.

The one unrelated failing test in the suite is the pre-existing timing-flaky burst_connections_above_cap_get_busy_response (tracked separately).

🤖 Generated with Claude Code

… wrapper)

Generalize a request's flat `pipeline` into a `Plan` tree the daemon executes
directly — fork/exec per stage with pipes, redirections, env, per-stage run-as,
and sequencing — and no shell on the privileged path. The structure the human
approves at the prompt is literally the structure that runs.

Schema (src/protocol.rs): a JSON-native, `op`-tagged `Plan` enum — run, pipe,
seq, redirect (incl. append and 2>&1), with-stdin-from, setenv, chdir, run-as
([ROOT]/[as user]), with-accepted-exit-codes, and an explicit `system` opaque
escape hatch. A recursive `validate_tree` reuses the existing has_dangerous_chars
gate on every string leaf and adds depth/node bounds, all inside the single
ValidatedRequest::validate constructor so the typestate guarantee is intact. A
flat `pipeline` desugars into a Plan, so every existing client keeps working;
effective_privileged = privileged || plan-contains-run-as closes the "unprivileged
request smuggling root" gap.

Executor (src/executor.rs): two passes — a pure compile (threads env/cwd/run-as/
stdio down the tree into a flat leaf list + sequencing tree) and a run pass
(pipe groups, seq short-circuit, one plan-wide deadline). Escalated leaves spawn
as `sudo [-u user] -- <literal argv>` / `pkexec [--user u] argv`, never `sudo
sh -c`; pipeline_to_shell and the pkexec sh -c branch are deleted. setenv is held
to the same env allowlist so it cannot become a bypass.

Prompt (src/tui.rs): every stage renders with its run-as tag and its own resolved
binary (fixing the vacuous single /usr/bin/bash resolves line); system stages are
flagged opaque; a stage-count cap keeps the [y/N] question on-screen. A single
plain unprivileged command keeps the classic one-line form.

Wiring (src/server.rs, src/mcp.rs): dispatch keys off effective privilege;
structurally-invalid plans are rejected before prompting; audit logs are
plan-aware; the MCP execute tool gained a `plan` parameter documented with the
seq-short-circuit and sudo-in-pipe caveats.

Tests: Rung-2 properties (render-vs-execute structural identity over random plans,
compile totality, a structural no-shell-on-privileged-path check), a Kani leaf
harness, tests/plan_exec.rs end-to-end, and wire-level plan validation tests.

backlog.md: captures the partial-seq retry hazard and the sudo-credential-in-pipe
caveat as a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cuihtlauac
cuihtlauac changed the base branch from feat/prompt-bell-blink to main October 16, 2026 18:10
@cuihtlauac
cuihtlauac force-pushed the feat/structured-plan branch from 75344fd to 98793db Compare October 16, 2026 18:10
cuihtlauac and others added 13 commits October 17, 2026 10:06
The plan model had `seq` (run in order, stop at the first non-accepted
failure, ≈ &&/;) but no run-on-failure branch, so `pgrep … || echo none`
was inexpressible (backlog "|| fallback" gap).

Add `alt` as the exact dual of `seq`: run steps left-to-right, continue
past a non-accepted failure, stop on the first success. A first-class op,
not a fold into `with-accepted-exit-codes` ("exit N counts as success" is
a different concept from "run the fallback because the prior step failed").

- protocol: `Plan::Alt { steps }` (serde `{"op":"alt",...}`); validate_tree
  rejects empty; contains_runas/contains_root treat it like seq so the
  privilege gate still holds.
- executor: `SeqNode::Alt`; compile_seq arm (same stdio-default guard as
  seq); compile_leaf rejects it as a pipe-stage/redirect body; run_seq
  inverts seq's stop condition (stop on success). Reuses exit_accepted and
  the plan-wide deadline unchanged. No new shell on the privileged path.
- prompt/docs: one-line render joins with " || "; MCP `plan` doc documents
  alt as the dual of seq.
- tests: 4 plan_exec cases (short-circuit-on-success, fallback-on-failure,
  all-fail-runs-every-step, accepted-exit-stops-alt), redirect-of-alt
  rejected without prompt, empty-alt rejection, wire round-trip; the Rung-2
  property tests now exercise alt via random_plan.

Closes the "|| fallback" backlog item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The personal, machine-specific project instructions are not meant to be
checked in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MCP `execute` plan docs advertise `{"op":"run-as","as":"root","do":{…}}`
(and `…,"as":"user","name":"…",…`), but `Plan::RunAs` held `who: RunAs` without
`#[serde(flatten)]`, so the deserializer required a nested `"who":{…}` object.
Any run-as plan written exactly as documented failed with `missing field 'who'`
— which breaks the headline feature that replaces the `sudo -H -u` blobs.

Flatten the `who` field so the ergonomic, documented `as` form both parses and
serializes (no nested `who`). The plan model is unreleased, so there is no
deployed client depending on the old nested shape. Docs were already correct;
only the code needed to match them.

Adds `run_as_wire_form_is_flattened_and_matches_docs` as a regression guard
(serializes to `as`/no `who`, round-trips, and the exact documented JSON for
both root and named-user forms deserializes).

Found via live end-to-end testing of the plan surface (finding F1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the MCP

The debug client could only build flat pipelines, so the full plan surface
(seq/alt/pipe/redirect/run-as/setenv/chdir/system) was unreachable from the CLI
and could only be exercised via the MCP tool or hand-written socket JSON.

Add `--plan JSON` and `--plan-file PATH` ('-' = stdin). When given, the client
assembles the request exactly as the MCP `execute` tool does (parse JSON into a
`Plan`, call `Request::new_plan`); `Request::new_plan` stamps a fresh timestamp,
so no socat or hand-rolled RFC3339 is needed. Plan and command/pipeline are
mutually exclusive; invalid JSON is caught client-side before anything is sent.

Note this reproduces the MCP's request *assembly*, not its policy layer — it
does not replicate the "refuse unprivileged local commands" rule, which is what
lets you drive unprivileged local plans for testing.

Docs: new docs/testing.md documents the two-terminal live-testing workflow
(branch daemon + sudo-request --plan, approve in your own terminal); docs/usage.md
gains --plan/--plan-file examples and a link to it.

Adds unit tests for plan assembly (valid plan builds + validates, bad JSON
rejected, no-plan still sends a pipeline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clarify the plan vocabulary and fill the sequencing gap surfaced during live
testing. The old `Seq` had `&&` semantics (stop on first failure) yet its name
implied `;` (run-all), which did not exist at all.

Renames (wire `op` tags change; all unreleased, no deployed client affected):
- `Run { argv }`        -> `Exec { argv }`     (op `exec`)  — one command
- `Seq { steps }` (&&)  -> `Run { steps }`     (op `run`)   — stop on first failure
- `System {..}`         -> `Shell {..}`        (op `shell`) — opaque escape hatch
- enum `Shell`          -> `ShellProc`         — the sh/bash interpreter

New op:
- `Seq { steps }` (op `seq`) — `;`: run every step regardless of exit; only a
  timeout or spawn failure aborts; status is the last step's. The dual of `Run`.

So the three chainers now map cleanly to shell: `run` = `&&`, `seq` = `;`,
`alt` = `||`. The approval prompt's one-line render uses those operators
(` && ` / ` ; ` / ` || ` / ` | `).

Updates every match site (protocol, executor incl. SeqNode + run_seq +
property-test generators, tui, mcp doc, sudo-request help/tests, Kani proof),
renames the `run()` test helper to `exec()` and the misleading `*_seq_*` test
names, adds `seq_runs_every_step_despite_failure`, and fixes op names in
docs/usage.md and docs/testing.md.

clippy -D warnings clean; lib 64 / plan_exec 13 / validation 22 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Security model vs. agent harnesses" section to docs/architecture.md and
a short cross-linked note in the README. Two axes: the gatekeeper (a mandatory
human keypress, no classifier/auto-approve — vs. a harness's software policy +
optional classifier + optional human), and privilege direction (sudo-proxy
escalates beyond what the agent has; a harness confines an agent that already
runs as you). The two are complementary, not competing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… levers

Record the plan model's raison d'être — make the `Shell` escape hatch
unnecessary (shrink-and-flag, not delete) — and three remaining levers: close
the `exec sh -c` loophole, implement the documented strict policy mode, and
catalogue the shell tail (detach, read-file) as first-class op proposals. Also
mark the append-redirection gap as landed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reorganize the multi-stage approval prompt: a single de-duplicated `Resolves:`
block sits between the command line and the numbered per-stage list, instead of
a resolve line repeated inside every stage. A binary used by several stages now
appears once (dedup by name + resolved path; distinct names stay apart even when
they share a multi-call binary like uutils). The per-stage list keeps every
stage with its run-as tag and redirections, but drops the per-stage resolve
line.

Colorize escalation: `[ROOT]` renders bold red and `[as user]` bold yellow
(both honor NO_COLOR, degrading to the plain text tags). `Style` gains `root`
and `user` escapes.

`plan_display` is unchanged, so the render-vs-execute structural-identity
property test still holds. Adds tests for dedup and root coloring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `--log-json` daemon flag (independent of `-v`) that logs each request's
command — just the `plan`, or a legacy `pipeline` — to stderr before validation,
pretty-printed (2-space indent). Serializes the typed `Plan` so keys keep their
on-wire order (`op` first, then fields in declaration order) rather than the
alphabetical order a serde_json::Value round-trip would impose — no new
dependency. Falls back to the raw line for non-JSON input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation coloring

Rework the approval prompt's command rendering:

- `Command:` is now a single, precedence-correct, copy-paste-executable shell
  line: tokens safely quoted (`shquote`), `setenv`/`chdir` as scoped subshells,
  nested composites parenthesized, and `run-as` threaded onto each leaf as
  `sudo`/`sudo -u` (matching the executor's per-leaf escalation). A
  privilege-free plan pastes and runs as you; test
  `command_oneliner_matches_executor_for_privilege_free_plan` runs the rendered
  line through `sh` and asserts it reproduces the executor's output.

- `Steps:` replaces the per-stage list with an indented, line-numbered
  (0-based, dim, flush-left) outline using `&&`/`||`/`|`/`;` and nesting, so a
  declined step can be referenced by line.

- Escalation coloring in two red intensities: a leaf that runs as root because
  the whole request is privileged is light red; the explicit `run-as`/`sudo`
  leaf is bright red; a named-user `run-as` is bright yellow. Operators of an
  all-escalated group are tinted too, so a privileged region reads as one block.
  Honors NO_COLOR. Three unit tests cover the combinations (all-light,
  one-bright-rest-default, all-light-one-bright).

The deduped `Resolves:` block is unchanged; `plan_display` is untouched so the
render-vs-execute property test still holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
command_summary seeded the escalation prefix only from run-as nodes, ignoring
the request's `privileged` flag — so a wholly-privileged plan with no run-as
rendered its Command one-liner with no `sudo` at all (pasting it would run as
you, not root), even though the Steps coloring showed it red. Seed the ambient
`sudo ` prefix from `req.privileged`, mirroring the executor; a run-as node still
overrides per-subtree. Each leaf is sudo'd individually (`sudo a && sudo b`),
never a single `sudo sh -c '…'` that would reintroduce a shell on the root path.

Adds command_threads_ambient_sudo_for_privileged_request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the variant rename, the short-circuit op is `run` (≈ `&&`), compiled to
`SeqNode::Run` — not `SeqNode::Seq` (which is now the `;`/run-all op). Update the
heading and the retry-hazard text to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…xecutor

Expand the run_pipe comment: pipe stages use setsid (not setpgid + foreground)
because a controlling terminal has a single foreground process group and pipe
stages run concurrently, so they can't each be handed the tty to prompt — the
daemon detaches them rather than arbitrate the tty across live members. Note the
framing that the daemon is now a shell's executor (fork/exec, pipes, process
groups, tty job control) minus the shell's parser — so "no shell on the
privileged path" means no shell language, not no process orchestration. Also
fixes the stale backlog item name (Partial-`run`, not `seq`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant