Skip to content

Require a person at a terminal to approve a decision - #37

Merged
shipmill-romamo[bot] merged 1 commit into
mainfrom
feat/25-approve-requires-person
Oct 8, 2026
Merged

shipmill-romamo[bot] merged 1 commit into
mainfrom
feat/25-approve-requires-person

Conversation

@shipmill-romamo

Copy link
Copy Markdown
Contributor

Summary

cloudfall decisions approve could be run by an agent with a shell: --yes ran it headless, and --approver fell 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.

  • Bumps treaty from 1.0.0rc29 to 1.0.0rc38, which brings requires_person and ctx.attest (Add ctx.attest and requires_person: a confirmation only a person gives romamo/treaty#425)
  • decisions approve is declared requires_person=True and keeps mcp=False. Before anything runs, the person types the decision id at a terminal, and no flag answers that prompt, --yes included
    • Off a terminal (an agent's shell, a script, --raw-payload) it exits 4 with PERSON_REQUIRED (retryable: false) and runs nothing
    • A mistyped id exits 4 with ATTESTATION_MISMATCH and runs nothing
  • The decision record's approval gains approval.via, a small enum value object (ApprovalChannel): terminal, taken from treaty's Attestation.channel. Records written before this read as via: unknown, and a new approval with an unknown channel is refused
    • The field is an additive, optional property in config/schemas/v1/operation-decision.schema.json, with the enum terminal or unknown
  • cloudfall why says 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 carries approval.via
  • The MCP operation tool's next hint, the command catalog's gate text, sdk/README.md and examples/disk-full-incident/README.md now say a person approves at a terminal, and the example README uses decisions show to preview

A 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

  1. The preview without --yes is gone, and --yes no longer runs the approval. treaty reserves --yes on a requires_person command and refuses to register a command that declares its own, so the old Flag(confirm=True) preview can't coexist with ctx.attest. The handler also has no public way to tell whether --yes was passed.
    • What this PR does: decisions approve <id> always asks for the typed id at a terminal, and refuses off one. Previewing moves to decisions show, the meta.dry_run preview output of decisions approve is gone, and --yes is now treaty's flag, which this command never needs. This matches treaty's own reference example for decisions.approve.
    • The alternative: keep a preview by renaming the run switch to something other than --yes (for example --run). That also changes the CLI.
  2. The treaty bump changes published output beyond this command:
    • dashboard serve and operator run now write each event in json/jsonl as a bare JSON object with _seq, instead of an envelope with data and meta.seq. They end with one "_summary": true line or the error envelope. test_dashboard_serve_streams_where_it_listens_until_stopped is updated to the new shape
    • mcp serve stopped by SIGINT or SIGTERM exits 130 or 143 with the CANCELLED envelope instead of 0, and writes no envelope when it stops cleanly
    • The manifest moves to ManifestResponse 3.19: commands that are not tools say mcp: false, and exit 6 entries list error_codes. A command declared ordered=True whose output has a set field gets a slightly different schema
    • meta.schema_version stays 1.0, although the stream change arguably calls for a MAJOR bump. The MCP tool list is unchanged, and decisions approve stays off it

Also noticed, not changed here: operator approve is served as an MCP tool on a project (operator.approve in agent_server.py) and has no person check.

Test plan

  • New CLI tests in sdk/tests/test_decision.py. The terminal paths run real ansible-playbook on a local playbook, through app.run with a stdin and stdout that report themselves as a terminal
    • Terminal, id typed: the decision is verified, and the record on disk has approval.via: terminal
    • Terminal, id mistyped: exits 4 with ATTESTATION_MISMATCH, and the record stays proposed
    • Headless with --yes and without it: exits 4 with PERSON_REQUIRED, and the record stays proposed
    • Headless through --raw-payload with "yes": true: exits 4 with PERSON_REQUIRED
    • The command is requires_person and mcp=False
    • An old record without via reads as unknown, and a new approval with unknown is refused
  • sdk/tests/test_why.py: the story and JSON show via terminal, and an old record shows via unknown
  • Updated: test_commands.py (decisions approve no longer has its own yes field), test_agent_server.py (next hint), and test_app.py (stream line shape)
  • Full checks after rebasing on main (9cb777c), all passing:
    • uv sync
    • uv run ruff check .
    • uv run mypy
    • uv run pytest: 403 passed
    • uv run cloudfall config validate --project config/examples
    • uv lock --check
  • Not verified: a real interactive terminal or pseudo-terminal session

Fixes #25

🤖 Generated with Claude Code

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>
@shipmill-romamo

Copy link
Copy Markdown
Contributor Author

@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 decisions approve's preview and --yes? On a command that requires a person, treaty reserves --yes for itself, and the handler can't tell whether it was passed. So "preview without --yes, run with --yes" can't sit next to the typed-id check.

  1. Preview moves to decisions show; decisions approve <id> always asks for the typed id at a terminal, and refuses off one with PERSON_REQUIRED (recommended; this is what the PR does, and it matches treaty's own decisions.approve example)
  2. Keep a preview inside approve, under a new run switch such as --run instead of --yes: also a CLI change, and two flags on one command

B. The treaty bump from rc29 to rc38 changes output beyond this command.

  • dashboard serve and operator run write each event as a bare JSON line with _seq instead of an envelope, ending with a _summary line
  • mcp serve stopped by a signal exits 130/143 with CANCELLED instead of 0
  • The manifest moves to 3.19
  1. Accept it in this PR, keep meta.schema_version at 1.0, and list the changes in the CHANGELOG (recommended: 0.6.0 is still a release candidate)
  2. Accept it and bump meta.schema_version to 2.0 for the stream change
  3. Split the treaty bump into its own PR first, then rebase this one on it

Related: operator approve is still an MCP tool with no person check; filed as #35, not in this PR.

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.

@romamo

romamo commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator

Decision by @romamo, given in chat; posted by Claude on his behalf.

A: 1. Preview moves to decisions show; decisions approve <id> always asks for the typed id at a terminal and refuses off one with PERSON_REQUIRED.

B: 1. Accept the treaty bump in this PR, keep meta.schema_version at 1.0, and list the output changes in the CHANGELOG. meta.schema_version first ships in 0.6.0, so no stable release has promised the 1.0 shape yet.

#35 (operator approve as an MCP tool) stays a separate release blocker.

@shipmill-romamo

Copy link
Copy Markdown
Contributor Author

Taking up the answer above (A1 B1). The PR already builds both:

  • A1: decisions approve <id> always asks for the typed id at a terminal and refuses off one with PERSON_REQUIRED; previewing is decisions show
  • B1: the treaty bump stays in this PR, meta.schema_version stays 1.0, and the CHANGELOG lists the stream and mcp serve exit changes under Changed and the approval change under Security

No revision needed. Removing needs-decision, marking the PR ready, and handing it to PR triage to merge when green. #35's person check follows once this lands; #38 still waits on its own question.

@shipmill-romamo shipmill-romamo Bot removed the needs-decision A shipmill session asked a question here; waits for a reply label Oct 8, 2026
@shipmill-romamo
shipmill-romamo Bot marked this pull request as ready for review October 8, 2026 20:50
@shipmill-romamo
shipmill-romamo Bot merged commit 964d889 into main Oct 8, 2026
1 check passed
@shipmill-romamo
shipmill-romamo Bot deleted the feat/25-approve-requires-person branch October 8, 2026 20:55
shipmill-romamo Bot added a commit that referenced this pull request Oct 9, 2026
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>
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.

decisions approve --yes runs headless, so an agent with a shell can approve its own proposal

1 participant