8000 T54 — Prompt the attached human on ask, refuse when nobody is attached, and audit every decision by minimal-foundry[bot] · Pull Request #1891 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

T54 — Prompt the attached human on ask, refuse when nobody is attached, and audit every decision - #1891

Merged
norrietaylor merged 19 commits into
mainfrom
agent/1669-prompt-the-attached-human-on-ask-refuse-when-nob
Oct 4, 2026
Merged

norrietaylor merged 19 commits into
mainfrom
agent/1669-prompt-the-attached-human-on-ask-refuse-when-nob

Conversation

@minimal-foundry
@minimal-foundry minimal-foundry Bot commented Oct 3, 2026 •
Copy link
Copy Markdown
Contributor

T54 — Prompt the attached human on ask, refuse when nobody is attached, and audit every decision

slice: S13
kind: task
covers: NET-045, NET-046
size: M
est: 300 code, 180 tests
executor: agent
difficulty: needs care
model: standard
diagnostics: the daemon's state dump carries the tail of the local audit log, with each decision, its box, its port and who decided
observability: one audit record per decision; one info log line per prompt shown and per refusal for want of a client
layers:
- daemon:
- crates/minimald/src/session_host.rs: a binding message prompts the attached client with the exit prompt's dialog, and answers no-one-attached when there is no binding
- crates/minimald/src/session.rs: routes an ask decision to the host
- crates/minimald/src/env.rs: relays the answer or the typed error to the in-box caller
- crates/minimald/src/audit.rs (new): the append-only local decision log under the state directory
- crates/minimald/src/lib.rs: declares the audit module
- crates/minimald/src/diag.rs: writes the audit log tail into the daemon's state dump
- crates/minimald/src/session_host/tests.rs: hosts the prompt and no-client tests
test: cargo nextest run -p minimald expose_ask_prompts_attached_human
test: cargo nextest run -p minimald expose_ask_without_client_refused
test: cargo nextest run -p minimald expose_unenrolled_decision_audited
- cli:
- crates/minimal/src/cmd/session.rs: on a VM-backed host the client records a yes answer for the port in the VM host daemon's table over its control socket before relaying the answer, so the publish that follows is admitted (NET-138)
test: cargo nextest run -p minimal ask_answer_recorded_in_host_table
produces:
- crates/minimald/src/audit.rs (entry point)
consumes: none
proof: An expose decided ask prompts the attached human and applies the answer, is refused with a typed error when nobody is attached, and every decision appears in the local audit log.
depends on:
- blocked by #1666
- blocked by #1711

Proof

A min net expose from inside a box whose dynamic_ingress is ask now renders the shell-exit prompt's own dialog (deny highlighted, so a reflexive Enter fails closed) on whoever is attached to that box's session, and the human's answer is what the in-box caller gets: an allow publishes — same request shape an allow box's request takes — and a deny or cancel is the box's own deny answer; with nobody attached the request is refused with the typed AskNeedsAnswer error, decided by the daemon and never parked on a dialog nobody can see (the session actor and the host loop both route the ask off their own loops so probes, policy reads and shutdown never wait behind a human). Every dynamic ingress decision — the box's, the attached human's, and the daemon's fail-closed one — is one JSON record in the append-only audit/decisions.log under the daemon's state directory naming box, port, decision, decider, outcome and the typed reason, and the daemon's state dump ships that log's tail.

Notes from the agent: CLI layer not implemented — the note cannot be built inside its layers, and the task's fourth test line (exit 4, no tests matched) reflects that. Two independent reasons: (1) the VM host daemon's control socket wire has no verb for it — minimald_rpc::BoxControlRequest carries only Register and Withdraw, Register always allocates a fresh address pair, and the publish gate the note targets reads a live row's admitted_ports (crates/minvmd/src/box_registry.rs), so a runtime port needs a new verb in crates/minimald-rpc/src/lib.rs plus crates/minvmd/src/control.rs and box_registry.rs — all outside this task's layers:; (2) the interactive client cannot learn the answer to record: session_via_ssh (crates/minimal/src/cmd/session.rs) attaches by shelling out to ssh -tt, so the dialog bytes and keystrokes flow terminal↔ssh↔daemon and the min process never sees them. The daemon does know the answer — it audited it — so the natural shape for th…[truncated]

Plan amendment and interim

  • The plan block was amended after round 1. The CLI layer (ask_answer_recorded_in_host_table) moved to T84 #1897: recording the answer in the VM host daemon's table needs a control verb that BoxControlRequest lacks. The exit-4 row in the table below is that removed line, so it is not a failure of this PR.
  • Interim on a VM-backed host: the admission is recorded only inside the guest, in the in-VM audit log. A process that escapes the box can erase that log. The host-side record of an answered yes is T84's.
  • live_ingress teardown belongs to #1884, not this PR.

After round 2

  • An ask whose dialog ends with no human answer (a shed, a superseding attach, the host going away, or an I/O failure rendering the dialog) is refused with AskNeedsAnswer and audited decided_by=daemon. An explicit keyed deny or cancel stays the human's DeniedByPolicy.
  • expose_ask_shed_mid_dialog_is_the_daemons_refusal and expose_ask_superseded_mid_dialog_is_the_daemons_refusal pin this. After an interrupted write, the shed path replaces the channel writer, so the shed notice and close still reach the client.
  • The live host's no-client branch logs one info line naming the box and port. expose_ask_after_detaching_is_refused_by_the_live_host asserts the host is live and that this line was logged.
  • Local check under cross: 10/10 ask tests pass, and the shed and supersede tests passed 3/3 re-runs.
  • The out-of-layer crates/minimald/src/session/tests.rs change only makes three test helpers pub(crate) so session_host/tests.rs can reuse them.
  • Round 3 is pending. A client that disconnects mid-dialog (EOF) must be the daemon's refusal, and the audit append must not follow a symlink.

Tests

line exit tail
cargo nextest run -p minimald expose_ask_prompts_attached_human 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-406b... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/f650f/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/f650f/tree/crates/ot) C
cargo nextest run -p minimald expose_ask_without_client_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-dfea... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/83007/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/83007/tree/crate
cargo nextest run -p minimald expose_unenrolled_decision_audited 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-17ed... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/2fb1a/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/2fb1a/tree/crate
cargo nextest run -p minimal ask_answer_recorded_in_host_table 4 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-7a8b... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/9f299/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/9f299/tree/crates/ot) C

Verify

min task run agent-verify passed ([tasks.verify]).

Out of scope

These files are outside the task's layers:, so this PR is a draft:

  • crates/minimald/src/session/tests.rs

Closes #1669

NET-046: every dynamic ingress decision gets one record in an
append-only log under the daemon's state directory, one JSON line
each naming the box, the port, the decision, who made it, the
outcome and any refusal reason.

- audit.rs: the append-only log itself (audit/decisions.log),
  one best-effort record per decision — a decision already made
  is never refused because its record could not be written; the
  failure is warned about instead.
- diag.rs: the state dump ships the log's tail beside the session
  records, skipping cleanly when the daemon has recorded no
  decisions yet.

The session-side wiring that writes these records lands next.
NET-045: a session decided 'ask' needs a human to answer, and the
human that can answer is whoever is attached — so the session host
learns to hand an ask to its binding, which renders the shell-exit
prompt's own dialog over the same channel halves.

- BindingMsg::AskExpose: the host hands the binding a port and the
  reply the answer goes back on.
- The binding stashes the ask in its mainloop and renders the
  dialog at the next iteration's head, because the select's own
  arms borrow the channel halves the dialog needs. Deny stands
  highlighted, so a reflexive Enter — a cancel, an EOF, a client
  that leaves mid-prompt — fails the request closed.
- The dialog is raced against the shed: a stalled client being
  discarded is not a client to wait on.
- Message::AskExpose + HostHandle::ask_expose: the session's route
  to whoever is attached, answering None when nobody is, which is
  the fail-closed nobody-attached case rather than an error.
NET-045: a runtime port-publish request a box decided 'ask' now
reaches whoever is bound to the session's host, and their answer is
the answer the in-box caller gets; nobody attached fails the request
closed with the typed nobody-is-attached error. Every decision —
the box's own, the human's, and the daemon's fail-closed one — is
logged and audited (NET-046).

- session.rs: the actor routes the ask to the host and parks the
  caller's reply in a pending-ask book, because parking the actor on
  a human would hang policy reads, manager probes and shutdown
  behind a dialog nobody may be looking at. The ask runs on a
  spawned task and comes back as AskAnswered, which answers the
  parked reply: an allow runs the publish half, a deny is the box's
  own deny answer in the human's hand, and nobody attached is the
  typed refusal. stop_running drains the book fail-closed so a
  session that goes away strands no asker.
- The policy gate moves out of publish_exposed_port into the
  decision, so the resumed ask publishes without re-deciding.
- session_host.rs: the host bridges the binding's answer on a
  spawned task, never by parking its own loop — a parked host stops
  pumping the pty and answering probes, and the probes that decide
  is_alive would report a live host dead.
- answer_expose writes the one info line per request (now naming who
  decided it) and the decision's audit record beside it; DecidedBy
  gains a Display so the line and the record spell the decider the
  same way.
- env.rs: the expose handler's docs say the reply can now wait for
  the human, and is the applied answer either way.
NET-045 end to end against a harness server, in the session-host
tests beside the binding they exercise:

- expose_ask_prompts_attached_human: a request a box decided ask
  renders the exit prompt's own dialog on the attached client's
  terminal, naming the box and the port; an allow publishes — the
  same request shape an allow box's takes — and the prompt, the
  answer, and the decision's audit record all say so.
- expose_ask_without_client_refused: nobody attached is refused with
  the typed nobody-is-attached error, the switch is asked nothing,
  the refusal says its log line with the typed reason, and the audit
  record names the daemon as the decider.
- expose_unenrolled_decision_audited: on the un-enrolled host, the
  allow that published and the deny-all default's refusal are both
  one parseable record each, naming box, port, decision, decider,
  outcome and the typed reason.
- ask_expose_without_a_binding_answers_no_one: the host-level arm —
  an ask reaching a host nobody is bound to answers None rather than
  parking.

session/tests.rs: the dynamic-ingress fixtures the new tests reuse
(the request builder, the finalize driver, the stand-in forwarder)
become pub(crate) — the sibling test module reaches them through
crate::session::tests, which session.rs now declares pub(crate)
under test.
The strict lint set denies a bare let-underscore on a must_use, so
each ignored oneshot send the ask path adds says why its result is
not the fact under test: an asker that is gone has nothing to answer,
a terminal that cannot take the lead-in cannot take the dialog, and
the host this test minted is not the test's own subject.
@coderabbitai
coderabbitai Bot commented Oct 3, 2026 •
Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 647e9f1a-0a7c-4e4a-9dde-f803a065a8fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@norrietaylor
Copy link
Copy Markdown
Member

/revise Round 1. All lanes are green at 70af41b. The daemon layers and their three tests are in. Four defects remain in the ask path.

  1. Long dialogs disconnect the human. While the dialog is up, the binding stops draining its 4-slot mailbox (session_host.rs:457, 528-547, 631). Four chunks of shell output fill it, the host stops reading the pty, and after OUTPUT_STALL_TIMEOUT (30 s, :1442) it sheds the binding. A human who takes longer than 30 s while anything prints is disconnected, and the ask is audited as a human refusal. Buffer BindingMsg::Stdin during the dialog and flush it afterwards, or suspend the stall deadline while an ask is outstanding. Test: shorten output_stall_timeout, have the mock shell emit output, answer after the bound, and assert that the binding was not shed and the port was published.
  2. Test the deny half. answer_ask_on(.., false) is never called (session_host/tests.rs:3502, :3590), so an implementation that ignored the answer would pass. Add expose_ask_human_deny_refused: assert the refusal, that served is empty, and an audit record with decided_by = attached-human and outcome refused.
  3. NET-015 at the session level. expose_ask_without_client_refused (session.rs:3657) never attaches the box, so it takes the no-host shortcut. Add a session-level variant that attaches, detaches, then asks. The host is live and remote is None, so the request must be refused with AskNeedsAnswer and audited.
  4. Key pending asks per ask, not per port. pending_asks (session.rs:2355) is keyed by port. A late None from a failed hand-off pops the first ask on that port while its dialog is still on screen. Key by a per-ask id carried through AskAnswered. Test: two asks on one port, the first's hand-off fails, and the second's answer is applied to the second.

Optional: give a human's deny its own refusal reason, distinct from DeniedByPolicy, so that the audit and the caller can tell the two apart. Do not reuse the deny code for anything else.

Out of scope this round: the CLI layer (ask_answer_recorded_in_host_table, recording the answer in the VM host daemon's table). It needs a control verb that BoxControlRequest lacks, and that is being planned separately. Leave a note in the PR body. Also out of scope: live_ingress teardown, which belongs to #1884.

@norrietaylor
Copy link
Copy Markdown
Member

Plan amended (T54 #1669): the CLI layer (ask_answer_recorded_in_host_table) is removed from T54. The host-side record of an answered yes moves to T84 #1897: the in-VM daemon reports it, and the VM host daemon records it within the stance and range held from the host-side registration. State the interim in this PR's body: on a VM-backed host the admission is recorded only inside the VM until T84 lands, and the in-VM audit log can be erased by a process that escapes the box until T84's host-side copy exists.

The dialog held the binding off its own mailbox, so a session that
printed more than the mailbox holds while the human thought wedged the
pty feed behind it: the host stopped reading the pty, and after
OUTPUT_STALL_TIMEOUT it shed the very client the dialog was waiting on,
answering the ask as the human's refusal.

The dialog now runs beside a drain of the mailbox. The output parks in
a buffer bounded at 4 MiB and flushes to the terminal once the dialog
is down, so past the bound the mailbox fills as it does for any client
that stopped keeping up and the stall bound - not an unbounded park -
decides what happens to the binding.

Two structural notes the drain forced:

- The four teardown arms share one rendering, because a teardown that
  interrupts a dialog renders through the same path as one that arrives
  between dialogs.
- The loop head revisits the dialog queue itself: an ask the dialog's
  drain queued while another stood has no message left to wake the
  select that would render it.
A parked ask was keyed by its port, so an answer popped whichever ask
parked first on that port: two exposes of the same port - a retry after
a failed hand-off is enough - sent the answer to the wrong asker, and
the one it was meant for never heard.

The session now mints an id per request and the continuation carries it
back, so every answer reaches the ask it answers. A late answer that
reaches no asker says one line and fabricates no decision, the same way
the audit treats a box that is gone.
Four tests, one per hole the review found in the ask dialog:

- the deny half of the dialog: the human denies, the publish is
  refused as policy would, and the audit records the decision as the
  human's, not the box's
- a session that prints over the stall bound while the dialog stands
  is not shed: the output parks behind the ask, the answer after the
  bound publishes, and the terminal receives every byte
- a live host nobody is attached to refuses the ask with the typed
  error, audits it as the daemon's refusal, and the box survives it
- two asks on one port: the failed hand-off's continuation reaches
  its own ask, the standing dialog still publishes, and the late
  answer says its line and fabricates no decision

The stall test needs the session printing while the dialog holds the
channel's reader, so the host takes a cfg(test) feed for its own pty
alongside a setter for the stall bound, and the actor takes a
cfg(test) peek at the parked asks so a test can name the ask it means
to answer.
@norrietaylor
norrietaylor marked this pull request as ready for review October 3, 2026 19:32
@norrietaylor
norrietaylor requested a review from a team as a code owner October 3, 2026 19:32
@minimal-foundry
minimal-foundry Bot commented Oct 3, 2026 •
Copy link
Copy Markdown
Contributor Author

Approved: no findings at 84d1314b2efc.

Nothing to do.

Verdict APPROVE, posted as a comment: the App cannot review its own pull request; the check and the label carry it.

Clobber: none: git log -- crates/minimald/src/session*.rs shows the touched lines built on prior main work (T22/T48/T53, #1884) and this PR's own branch commits; nothing reverts a recent fix.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — advisory review

Verdict COMMENT — 1 finding, 1 inline.

Comment thread crates/minimald/src/session_host.rs Resolved
@norrietaylor
Copy link
Copy Markdown
Member

/revise

Round 2. The round-1 asks hold at ae4108f: the mailbox drains during the dialog, the deny test passes, and asks are keyed by AskId. The open review thread at session_host.rs:613 is real. Three asks, nothing else. Scope: crates/minimald/src/session_host.rs, crates/minimald/src/session.rs and crates/minimald/src/session_host/tests.rs only.

  1. A dialog that ends without an answer is the daemon's refusal, not the human's. In Binding::run, answer starts as AskAnswer::Refused (around :592). Six arms break out with that value and send it as the human's answer: shed (:613), host-gone (:651) and the four farewell arms (:634-646). resume_ask then records decided_by=attached-human / DeniedByPolicy. Fix it like this:

    • Make answer an Option<AskAnswer> starting at None. Only the arm where the dialog completes sets Some.
    • At the reply sends (around :672 and :683), send only on Some; otherwise drop the reply sender. The host then sees no answer, and resume_ask audits decided_by=daemon with AskNeedsAnswer.
    • In ask_prompt (around :1007-1011), map an I/O error to no answer, not Refused. An explicit cancel stays the human's deny.
      Test expose_ask_shed_mid_dialog_is_the_daemons_refusal:
    • shorten the stall bound;
    • with the dialog up and the client not reading, push more than ASK_HELD_OUTPUT into the pty so the binding sheds;
    • assert AskNeedsAnswer, nothing served, and o 8000 ne audit record with decided_by daemon and outcome refused.
  2. The same rule for a teardown mid-dialog. Test expose_ask_superseded_mid_dialog_is_the_daemons_refusal: while the dialog is up, open a second shell on the box so the first binding is superseded. Assert AskNeedsAnswer and decided_by=daemon.

  3. Prove the live-host path in the detach test. expose_ask_after_detaching_is_refused_by_the_live_host cannot yet tell the host's remote=None branch (session_host.rs around :4885) from the no-host shortcut (session.rs around :2328-2350).

    • In the remote=None branch, add the plan's observability line: one info line per refusal for want of a client, naming the box and the port.
    • In the test, assert the host is live before the ask without reattaching, and assert that this line was logged.

Add the three test names as test: lines in the result table. Out of scope: the refusal reason for an explicit human deny stays DeniedByPolicy; no CLI changes; live_ingress teardown; no PR-body rewrite.

A dialog that ends without the human's choice — a shed, a teardown, the
host going away, a terminal that could not carry it — is the daemon's
fail-closed refusal, not the human's deny. `Binding::run` broke every
such path out of the dialog with the default `Refused` answer and sent
it up as the human's own, so `resume_ask` audited `decided_by =
attached-human` for a denial no human made. Now only a dialog that
completed with a choice in hand answers; every other end drops the
reply sender, which the host reads as nobody-attached, and
`ask_prompt` maps a render/read I/O failure to no answer rather than
a deny, while an explicit cancel stays the human's.

The host's nobody-attached branch also says the plan's observability
line now — one info line per refusal for want of a client, naming the
box and the port — which is what tells a live host's refusal apart
from the session's no-host shortcut in the detach test, and that test
now asserts both.

Tests: a shed mid-dialog and a supersede mid-dialog each end their ask
with the typed nobody-is-attached refusal, audited as the daemon's.
russh's channel writer keeps the state of a write cancelled mid-send:
ChannelTx parks its send in `send_fut` and answers the next `write_all`
with the interrupted write's byte count, a count that can run past a
shorter buffer's end - where tokio's `write_all` panics (`split_at`:
mid > len). The shed notice and every farewell are the shorter
buffers, so a binding shed while its output flooded (or mid-dialog)
died in its own epilogue and left the client hanging on a channel
that never closes.

The state is per-writer, so the mainloop now replaces the writer at
every race that drops a write - the dialog select, the held flush,
and the plain relay race - and the shed notice rides a clean writer.
The cost is the interrupted chunk alone, unsent with its window space,
at most one max_packet_size, and only ever on a client that stopped
reading.

The shed-mid-dialog test caught this as a flake (a coin flip on the
select's branch order) and now also asserts the notice still reaches
the client after the flood.
@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — advisory review

Verdict COMMENT — 2 findings, 2 inline.

Comment thread crates/minimald/src/audit.rs Resolved
Comment thread crates/minimald/src/audit.rs Resolved
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 2 of 6 on a /revise note, pushed as 60183ba65497.

line exit tail
cargo nextest run -p minimald expose_ask_prompts_attached_human 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-dfc9... Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/24aa3/tree/crates/ot) Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/24aa3/tree/crates/args) C
cargo nextest run -p minimald expose_ask_without_client_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-4562... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/c9919/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/c9919/tree/crate
cargo nextest run -p minimald expose_unenrolled_decision_audited 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-6043... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/5c4d1/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/5c4d1/tree/crate

Verify min task run agent-verify passed.

Outside the task's layers::

  • crates/minimald/src/session/tests.rs

Converted to a draft: something above needs a person before review.

Review threads the coder judged:

  • 👍 valid — PRRT_kwDOPdfcxM6oq74C: the binding carries no answer until the human gives one: a shed mid-dialog now drops the reply sender and resume_ask audits the daemon as the decider with the typed nobody-is-attached refusal (expose_ask_shed_mid_dialog_is_the_daemons_refusal) (not posted on the thread: not an open thread on the pull request)

An expose decided ask that reaches a human prompts them and applies their answer, and every decision lands in the local audit log; one whose dialog ends without a human answer — a terminal shed for stalling, a superseding attach, the host going away, a terminal that could not carry the dialog — is refused with the typed nobody-is-attached error and audited as the daemon's decision (decided_by=daemon, outcome=refused), never as the human's deny. The host's nobody-attached branch now says its one info line naming the box and the port, and the detach test proves both that the refusing host was live and that this line was said — so the live-host refusal is told apart from the session's no-host shortcut. A shed mid-dialog still closes the client's channel — EOF, exit status 255, and the stall notice delivered even when the shed dropped a write mid-send.

Notes from the agent: Three asks done: (1) Binding::run carries Option, None until a dialog completes under the human; every other exit from the dialog select (shed, the four teardown arms, host gone) drops the reply sender, which resume_ask records as decided_by=daemon with AskNeedsAnswer; ask_prompt maps an I/O-dead dialog to None while an explicit cancel stays the human's Refused. (2) The same rule for a supersede mid-dialog. (3) The remote=None branch of Message::AskExpose says one info line per refusal for want of a client (session=, port), and the detach test asserts a live host (get_attrs) before the ask and then this line. The session.rs change is comment-only: round 1's code already routed None to the typed refusal. Out of scope kept out: an explicit human deny still refuses with DeniedByPolicy; no CLI change; no live_ingress teardown; no PR-body rewrite. Flake found and fixed while confirming the shed test: the first version failed on re-run because russh 0.63.3's ChannelTx keeps the state of a write cancelled mid-send — it parks the send in send_fut and answers the next write_all wi…[truncated]

@minimal-foundry
minimal-foundry Bot marked this pull request as draft October 3, 2026 21:12
@norrietaylor
Copy link
Copy Markdown
Member

/revise

Round 3. Round 2's asks are met: no answer means None, the shed and supersede tests pass, and the live-host info line is in. Two asks, nothing else.

  1. A client that goes away mid-dialog is the daemon's refusal, not the human's deny. async-dialog returns Selection::Cancelled for EOF (crates/async-dialog/src/select.rs:130), the same value as Ctrl-C, q or Escape. ask_prompt maps it to Some(Refused) (session_host.rs:~1079), and session.rs:~2412 calls EOF "the human's deny". Under a raw ssh -tt tty no keystroke produces channel EOF, so EOF means the connection or client went away. That is a terminal that can't carry the dialog, and it must be audited decided_by=daemon with AskNeedsAnswer.
    • In session_host.rs, wrap the dialog's reader so ask_prompt can tell input EOF from a keyed cancel, and map EOF to None.
    • Do not change async-dialog.
    • Fix the session.rs comment.
    • Test: expose_ask_client_eof_mid_dialog_is_the_daemons_refusal closes the client's channel (EOF) while the dialog is up. It asserts AskNeedsAnswer, nothing served, and exactly one audit record with decided_by=daemon.
    • Keep expose_ask_human_deny_refused passing: an explicit keyed cancel is still the human's.
  2. The audit append must not follow a symlink (crates/minimald/src/audit.rs, the open MEDIUM thread at :141). The state volume is guest-writable on a VM host, so a planted audit/decisions.log symlink, or a symlinked audit/ directory, would redirect the daemon's appends.
    • Open the file with custom_flags(libc::O_NOFOLLOW), as diagnostics::bundle::open_regular_nofollow does.
    • After create_dir_all, check the audit/ directory with symlink_metadata, and refuse to write unless it is a real directory, logging the existing warn line with the reason.
    • Test: audit_append_refuses_a_symlinked_log covers both the file and the directory cases. Each must leave the symlink target untouched and log the warn line.

Out of scope: log rotation and caps (the LOW at audit.rs:108, a follow-up), the out-of-layer session/tests.rs visibility change (justified), and the host-gone arm's missing test. Do not rewrite the PR body.

The write side of the log's symlink guard, which the read side
(diag.rs's audit collector) already held: the state volume is
guest-writable on a VM host, so a planted decisions.log — or a planted
audit/ directory, which create_dir_all follows — would otherwise steer
the daemon's per-decision appends into a target of someone else's
choosing, with this daemon's privileges.

The file now opens with O_NOFOLLOW exactly as diagnostics' read of the
same file does, and the audit/ directory is refused unless
symlink_metadata says it is a real directory; both refusals log the
loss through the warn line that already names it.
async-dialog reports an input EOF as the same Selection::Cancelled a
keyed Ctrl-C produces, so ask_prompt read the two alike and the audit
recorded a denial no human made. Under a raw ssh -tt tty no keystroke
produces channel EOF: EOF means the connection or client went away
with the dialog standing, a terminal that can no longer carry it —
nobody answered, and that is the daemon's refusal, not the human's
deny.

The dialog now reads through a wrapper that records the input EOF
without touching the bytes, and ask_prompt maps it to no answer —
the same road a shed or teardown takes — so resume_ask records
decided_by=daemon with the typed nobody-is-attached refusal. A keyed
cancel stays the human's own deny, and the session.rs comment no
longer calls EOF the human's one.
The local held the render's outcome — an allow, a keyed cancel, an
input EOF, an I/O failure — so a name that says only the keyed half
reads as if the EOF arms below were unreachable.
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 3 of 6 on a /revise note, pushed as 69f0bcc5ff40.

line exit tail
cargo nextest run -p minimald expose_ask_prompts_attached_human 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-30ef... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/d70c3/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/d70c3/tree/crates/ot) C
cargo nextest run -p minimald expose_ask_without_client_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-bd35... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/15165/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/15165/tree/crate
cargo nextest run -p minimald expose_unenrolled_decision_audited 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-c668... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/a7659/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/a7659/tree/crate

Verify min task run agent-verify passed.

Outside the task's layers::

  • crates/minimald/src/session/tests.rs

Converted to a draft: something above needs a person before review.

Review threads the coder judged:

  • 👍 valid — crates/minimald/src/audit.rs: append now opens decisions.log with custom_flags(libc::O_NOFOLLOW) — as the diag read of the same file does — and refuses to write unless symlink_metadata says audit/ is a real directory; both refusals log the existing warn line with the reason. audit_append_refuses_a_symlinked_log covers the file a
  • 👎 invalid — crates/minimald/src/audit.rs:108: The premise holds — the log is append-only with no rotation or cap — but this round's directive names rotation and caps out of scope as a follow-up, so nothing changed for it here; the next round that owns state-directory hygiene should pick it up.

An expose decided ask prompts the attached human and applies the answer; every way the dialog can end without a human's choice — nobody attached, a shed, a teardown, and now a client whose channel reaches EOF mid-dialog — is refused with the typed nobody-is-attached error and audited decided_by=daemon, while a keyed cancel stays the human's deny; every decision appears in the local audit log (on a VM-backed host, in-guest only, with the host-side record of an answered yes still T84's), and the log's append now refuses a planted symlink in the file's or the audit/ directory's place.

Notes from the agent: Both asks of round 3 are done, each in its own commit; async-dialog was not touched. All four files are inside the task's layers. clippy-strict still reports 9 let_underscore_must_use hits on render_farewell's let _ = w.write_all(...) lines (session_host.rs ~1039-1085) — lines this branch changed in an earlier round, which this round's directive did not name, so they are reported here rather than edited; this round's own diff is strict-clean (verified with just clippy-strict 60183ba65497). Not run in this sandbox: just e2e/just test-vm (no KVM host, no jq/cpio) and just test-cross (needs Docker) — the in-guest daemon this guard protects is compiled by the cross lane in CI. The rotation/caps LOW is deferred by the directive as a follow-up.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — advisory review

Verdict COMMENT — 1 finding, 1 inline.

Comment thread crates/minimald/src/audit.rs Resolved
`append` checked `audit/` with `symlink_metadata` and then opened
`decisions.log` by full path, `O_NOFOLLOW` on the final component only:
on the guest-writable state volume a guest that swaps `audit/` for a link
between the check and the open steers the daemon's append — with the
daemon's privileges — through it into a target of its own choosing, and
the check cannot see it. Nothing is checked now: the directory is opened
`O_NOFOLLOW | O_DIRECTORY`, and the log is `openat`ed relative to that
descriptor, also `O_NOFOLLOW`, so both components of the log's path are
pinned at the moment they are opened and there is no window at all. A
link standing in either place is refused at open, the same refusal
`crate::diag`'s audit collector makes on the read side, and the warn
that reports the loss names the half of the path that refused.
@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — approved

Verdict APPROVE — No findings.

@minimal-foundry minimal-foundry Bot added the review:approved minimal-review approved the current head label Oct 3, 2026
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 4 of 6 on the runtime's review findings, pushed as 84d1314b2efc.

line exit tail
cargo nextest run -p minimald expose_ask_prompts_attached_human 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-f19f... Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/59721/tree/crates/ot) Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/59721/tree/crates/args) C
cargo nextest run -p minimald expose_ask_without_client_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-4106... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/8871a/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/8871a/tree/crate
cargo nextest run -p minimald expose_unenrolled_decision_audited 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-cb1b... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/bbb8a/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/bbb8a/tree/crate

Verify min task run agent-verify passed.

Outside the task's layers::

  • crates/minimald/src/session/tests.rs

Converted to a draft: something above needs a person before review.

Review threads the coder judged:

  • 👎 invalid — PRRT_kwDOPdfcxM6orq84: Already so at this head: append has refused a symlinked decisions.log with O_NOFOLLOW and refused a symlinked audit/ since 69f0bcc (round 3), and this round keeps both halves of that guard while moving them into the open itself — the file is now opened through an O_NOFOLLOW | O_DIRECTORY (not posted on the thread: not an open thread on the pull request)
  • 👍 valid — PRRT_kwDOPdfcxM6osrWa: Closed the race by anchoring the open: audit/ is opened O_NOFOLLOW | O_DIRECTORY and decisions.log is openated against that descriptor with O_NOFOLLOW, so no component of the log's path is resolved after it was checked — there is no window for a swap. The standing file- and directory-symli (not posted on the thread: not an open thread on the pull request)

An expose decided ask prompts the attached human and applies the answer, is refused with a typed error when nobody is attached, and every decision appears in the local audit log — whose appends are now anchored to the audit directory the daemon opened itself (O_NOFOLLOW | O_DIRECTORY, then openat with O_NOFOLLOW), so a link planted at any component of the log's path is refused at open and no check-to-open window remains for a swap; on a VM-backed host the admission is still recorded in-guest only, and the host-side record of an answered yes is T84's.

Notes from the agent: Round 4 addressed the race the review named on audit.rs's append: the symlink_metadata pre-check on audit/ plus the full-path O_NOFOLLOW open is replaced by an open that is anchored — audit/ opened O_NOFOLLOW | O_DIRECTORY (O_CLOEXEC), decisions.log then openated against that descriptor with O_NOFOLLOW | O_CREAT | O_APPEND | O_CLOEXEC — so both components of the log's path are pinned at the moment they are opened. Consequences a reviewer should see: (1) the created log is now mode 0o600 rather than the umasked default create(true) gave, which only the daemon's own diagnostic collector reads; (2) the refusal's warn line now names which half refused (the audit directory could not be opened / the audit log could not be opened, the errno beside it), and audit_append_refuses_a_symlinked_log asserts those instead of the old symlink_metadata wording. The narrow race itself is not testable without a hook into the two opens, so it is closed by construction and the test's doc comment says so. Additional checks run beyond the test: lines: cargo fmt --all -- --check clean, cargo clippy -p minimald --all-targets --locked -- -D warnings exit 0 (the pinned flags of just clippy, scoped to the one crate touched), and just clippy-strict exit 2 with 9 hits — all clippy::let_underscore_must_use on let _ = w.write_all(..) lines inside session_host.rs's `r…[truncated]

@minimal-foundry
minimal-foundry Bot marked this pull request as draft October 3, 2026 23:39
@norrietaylor
Copy link
Copy Markdown
Member

Un-drafting. Round 4 (fa87d50) closes the review MEDIUM: open_log opens audit/ with O_NOFOLLOW | O_DIRECTORY and the log with openat(..., O_NOFOLLOW), so there is no window between check and open. 84d1314 also merges main. The out-of-layer file is still the one justified before. Design review: PASS carries. It assumes the state root and its ancestors are not box-writable.

@norrietaylor
norrietaylor marked this pull request as ready for review October 4, 2026 02:59
@norrietaylor norrietaylor left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Own review and ARCH PASS at 84d1314; CI green; review concluded; no open threads.

@norrietaylor
norrietaylor added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 3bd9a48 Oct 4, 2026
34 checks passed
@norrietaylor
norrietaylor deleted the agent/1669-prompt-the-attached-human-on-ask-refuse-when-nob branch October 4, 2026 03:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review:approved minimal-review approved the current head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

T54 — Prompt the attached human on ask, refuse when nobody is attached, and audit every decision

1 participant

0