Repository navigation
Require a person at a terminal to approve a decision - #37
Conversation
Bump treaty to 1.0.0rc38 for requires_person and ctx.attest. decisions approve now asks the person to type the decision id at a terminal; no flag answers it, so an agent's shell call exits 4 with PERSON_REQUIRED and a mistyped id with ATTESTATION_MISMATCH. The record keeps how the approval arrived as approval.via (terminal; unknown for older records), and cloudfall why tells it. treaty reserves --yes on a requires_person command, so the old Flag(confirm=True) preview without --yes is gone; decisions show previews. This departs from the decided design and waits on the maintainer. The treaty bump also changes the stream line shape of dashboard serve and operator run, and mcp serve's signal exit codes. Fixes #25 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@romamo Decision needed: this PR builds option 1 from #25, but treaty rc38 doesn't allow it exactly as decided. Two questions. A. What happens to
B. The treaty bump from rc29 to rc38 changes output beyond this command.
Related: Checks pass locally (403 tests). Not verified on a real terminal: the tests use a stream that reports itself as one. Reply with a letter and number each (e.g. "A1 B1"); shipmill takes this up on the tick after your reply. |
|
Decision by @romamo, given in chat; posted by Claude on his behalf. A: 1. Preview moves to B: 1. Accept the treaty bump in this PR, keep #35 ( |
|
Taking up the answer above (A1 B1). The PR already builds both:
No revision needed. Removing |
operator approve ran a proposal's playbook and its verify step on any shell call, so an agent with a shell could approve its own proposal. It now follows decisions approve (#37): requires_person=True, and ctx.attest asks the person to type the proposal id before anything runs. No flag answers it, --yes and a raw payload's "yes" included; off a terminal it exits 4 with PERSON_REQUIRED, a mistyped id with ATTESTATION_MISMATCH, and the proposal stays proposed. operator show is the preview. A person's approval on the receipt records approval.via (terminal; a human approval written before reads as unknown; a policy's has none), reusing ApprovalChannel from the decision record. The field is optional in operator-proposal.schema.json. requires_person implies mcp=False, so on its own this breaks `mcp serve --project`: its bound gateway_url no longer names a served tool (MCP_BIND_UNKNOWN). It lands after #38, which takes the gateway flags off mcp serve. Part of #35 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
cloudfall decisions approvecould be run by an agent with a shell:--yesran it headless, and--approverfell back to$USER, so the record named the person for an approval the agent gave itself. This PR implements option 1 from #25 on treaty's new person-only confirmation.treatyfrom1.0.0rc29to1.0.0rc38, which bringsrequires_personandctx.attest(Add ctx.attest and requires_person: a confirmation only a person gives romamo/treaty#425)decisions approveis declaredrequires_person=Trueand keepsmcp=False. Before anything runs, the person types the decision id at a terminal, and no flag answers that prompt,--yesincluded--raw-payload) it exits 4 withPERSON_REQUIRED(retryable: false) and runs nothingATTESTATION_MISMATCHand runs nothingapproval.via, a small enum value object (ApprovalChannel):terminal, taken from treaty'sAttestation.channel. Records written before this read asvia: unknown, and a new approval with an unknown channel is refusedconfig/schemas/v1/operation-decision.schema.json, with the enumterminalorunknowncloudfall whysays how the approval arrived, for example "roman approved it at 2026-09-21T14:30:12Z (via terminal): a person typed its id at a terminal.", and its JSON carriesapproval.vianexthint, the command catalog's gate text,sdk/README.mdandexamples/disk-full-incident/README.mdnow say a person approves at a terminal, and the example README usesdecisions showto previewA TTY check is a speed bump against an agent that can drive a pseudo-terminal, not a boundary. A process running as the same OS user can fake a terminal. The real boundary is a secret or an OS user the agent can't reach (option 3, out of scope here).
Decisions for you
--yesis gone, and--yesno longer runs the approval. treaty reserves--yeson arequires_personcommand and refuses to register a command that declares its own, so the oldFlag(confirm=True)preview can't coexist withctx.attest. The handler also has no public way to tell whether--yeswas passed.decisions approve <id>always asks for the typed id at a terminal, and refuses off one. Previewing moves todecisions show, themeta.dry_runpreview output ofdecisions approveis gone, and--yesis now treaty's flag, which this command never needs. This matches treaty's own reference example fordecisions.approve.--yes(for example--run). That also changes the CLI.dashboard serveandoperator runnow write each event injson/jsonlas a bare JSON object with_seq, instead of an envelope withdataandmeta.seq. They end with one"_summary": trueline or the error envelope.test_dashboard_serve_streams_where_it_listens_until_stoppedis updated to the new shapemcp servestopped bySIGINTorSIGTERMexits 130 or 143 with theCANCELLEDenvelope instead of 0, and writes no envelope when it stops cleanlymcp: false, and exit 6 entries listerror_codes. A command declaredordered=Truewhose output has a set field gets a slightly different schemameta.schema_versionstays1.0, although the stream change arguably calls for a MAJOR bump. The MCP tool list is unchanged, anddecisions approvestays off itAlso noticed, not changed here:
operator approveis served as an MCP tool on a project (operator.approveinagent_server.py) and has no person check.Test plan
sdk/tests/test_decision.py. The terminal paths run realansible-playbookon a local playbook, throughapp.runwith a stdin and stdout that report themselves as a terminalapproval.via: terminalATTESTATION_MISMATCH, and the record staysproposed--yesand without it: exits 4 withPERSON_REQUIRED, and the record staysproposed--raw-payloadwith"yes": true: exits 4 withPERSON_REQUIREDrequires_personandmcp=Falseviareads asunknown, and a new approval withunknownis refusedsdk/tests/test_why.py: the story and JSON showvia terminal, and an old record showsvia unknowntest_commands.py(decisions approveno longer has its ownyesfield),test_agent_server.py(nexthint), andtest_app.py(stream line shape)main(9cb777c), all passing:uv syncuv run ruff check .uv run mypyuv run pytest: 403 passeduv run cloudfall config validate --project config/examplesuv lock --checkFixes #25
🤖 Generated with Claude Code