Repository navigation
feat(plan): structured composite-execution plans (retire the bash -lc wrapper) - #59
Open
cuihtlauac wants to merge 14 commits into
Open
cuihtlauac wants to merge 14 commits into
cuihtlauac wants to merge 14 commits into
Conversation
… 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
force-pushed
the
feat/structured-plan
branch
from
October 16, 2026 18:10
75344fd to
98793db
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
A captured approval trail showed every command arriving as
argv = ["bash","-lc","<script>"]. That hollows out the gate two ways: theResolves:line is always/usr/bin/bash(the real binaries are chosen by$PATHinside the script), and one root approval authorizes a whole bundle of effects hidden in a wall of text.This generalizes a request's flat
pipelineinto aPlantree 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
src/protocol.rs): JSON-nativeop-taggedPlanenum —run,pipe,seq,redirect(incl. append +2>&1),with-stdin-from,setenv,chdir,run-as([ROOT]/[as user]),with-accepted-exit-codes, and an explicitsystemopaque escape hatch. Recursivevalidate_treereuses the existinghas_dangerous_charsgate on every leaf + depth/node bounds, inside the oneValidatedRequest::validateconstructor (typestate preserved). A flatpipelinedesugars into aPlan, so existing clients are unaffected;effective_privileged = privileged || plan-contains-run-ascloses the unprivileged-smuggling-root gap.src/executor.rs): two passes (pure compile → leaf list + sequencing tree; run → pipe groups,seqshort-circuit, one plan-wide deadline). Escalated leaves spawn assudo [-u user] -- <literal argv>/pkexec [--user u] argv, neversudo sh -c.pipeline_to_shelland thepkexec sh -cbranch are deleted.setenvis held to the env allowlist so it can't become a bypass.src/tui.rs): every stage renders its run-as tag and its own resolved binary (fixing the vacuous single/usr/bin/bashline);systemstages flagged opaque; a stage-count cap keeps[y/N]on-screen. Simple single commands keep the classic one-line form.src/server.rs,src/mcp.rs): dispatch keys off effective privilege; structurally-invalid plans are rejected before prompting; audit logs are plan-aware; the MCPexecutetool gained aplanparameter, documented with the actionableseq-short-circuit and sudo-in-pipe caveats.Design decisions
sudo(not a root daemon). The daemon stays same-uid; escalation is per-stagesudo/pkexecwith literal argv. Removes the shell from the privileged path with no deployment or threat-model change. (A root-daemon + direct-setuidmodel is a future, separately-gated milestone.)setsid(a pipelined child can't interactively prompt, so it detaches and relies on cached credentials — matching prior behavior); a single escalated leaf usessetpgid+ 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; strictclippy -D warningsclean; 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