Skip to content

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

Closed

Description

@romamo

What happens

decisions approve is kept off the MCP surface on purpose (mcp=False, "A person approves; no agent tool stands in for it"). But an agent with a shell, the common setup for coding agents, can run the CLI directly:

$ cloudfall decisions approve shop-logrotate-20261008152628 --yes

It runs headless (meta.headless: true), and --approver falls back to $USER, so the record shows the human as approver of a run the agent approved for itself.

Evidence that agents try

In a spike, an agent was told in its system prompt that only the operator approves. Nemotron 3 Ultra still tried to approve its own proposal in 2 of 3 runs, once 10 times in a row, guessing operations approve, approve and playbooks run. It did not find decisions approve only by chance; the harness blocked the rest.

Expected

The approval should not depend on the agent following instructions. Options:

  • refuse --yes when headless unless an explicit approval token or interactive confirmation is present
  • require a typed confirmation on a TTY (the decision id or its diff hash) before running
  • record how the approval arrived (TTY, token, policy) so cloudfall why can tell a human approval from an unattended one

Not filed as a vulnerability: the documented agent surface is MCP, which already excludes the command. This hardens the CLI path.

Activity

  1. shipmill-romamo commented on Oct 8, 2026

    @shipmill-romamo
    Contributor

    Triage: implement, for the next release candidate. Waits on your decision below.

    Confirmed on main (d84a2ae): decisions approve is mcp=False (sdk/src/cloudfall/app.py), but from a shell cloudfall decisions approve <id> --yes runs with no further check, and --approver falls back to $USER, so the record names the human for an approval the agent gave. Nothing in the record says how the approval arrived.

    The plan, once the design is settled:

    • Gate --yes in decisions_approve on how the command was started, per the option chosen below
    • Record how the approval arrived in the decision record (approval.via), and have cloudfall why print it
    • CLI tests for each path (TTY confirmed, TTY mistyped, headless refused or recorded)

    Design. This changes a flag's behaviour and the decision record's schema, so it goes through the design gate. Compatibility: anyone who approves from a script or CI today breaks under option 1; the new approval.via field is additive, and records written before it read as via: unknown. No decisions log exists yet, so nothing settled is departed from.

    A caveat for every option: an agent with a shell can drive a pseudo-terminal, so a TTY check is a speed bump, not a boundary. The real boundary is a secret or an OS user the agent can't reach, which is a larger feature. I'll link the PR here.

  2. shipmill-romamo commented on Oct 8, 2026

    @shipmill-romamo
    Contributor

    @romamo Decision needed: how should cloudfall decisions approve --yes behave when no person is at a terminal?

    1. Refuse it headless, and on a terminal ask the person to type the decision id (recommended): an agent's plain shell call fails with a clear message, and every approval is recorded as typed at a terminal; scripted or CI approvals stop working until a token or policy feature exists
    2. Keep it working headless, but record how it arrived (terminal or headless) and show that in cloudfall why: nothing breaks, but an agent can still approve its own proposal; it is only caught afterwards
    3. Require an approval token headless (a file only the operator's account can read), and a typed confirmation on a terminal: the strongest, and keeps unattended approvals possible, but needs a separate OS user for the agent and a new setup step

    Reply here with a number or your own answer; shipmill takes this up on the tick after your reply.

  3. romamo commented on Oct 8, 2026

    @romamo
    CollaboratorAuthor

    Decision by @romamo, relayed by Claude Code: 1. Refuse --yes headless; on a terminal the person types the decision id; record how the approval arrived (approval.via) and show it in cloudfall why.

    On hold: waits on romamo/treaty#424, ctx.attest + requires_person. Treaty's prompts all have an agent-answerable flag by design, so the person-only confirmation is being added to Treaty first; Cloudfall then bumps from treaty rc29.

  4. removed
    needs-decisionA shipmill session asked a question here; waits for a reply
    on Oct 8, 2026
  5. shipmill-romamo commented on Oct 8, 2026

    @shipmill-romamo
    Contributor

    Triage: still implement as decided above (option 1), now waiting on a treaty release rather than a treaty decision.

    romamo/treaty#424 closed: ctx.attest and requires_person merged in romamo/treaty#425 as f10143b (2026-10-08 16:50 UTC). But cloudfall pins treaty==1.0.0rc29 from PyPI, and treaty's newest release, 1.0.0rc37, was cut on 2026-10-07, before that merge. A treaty release run started at 16:50 UTC; the two before it failed.

    On hold: waits on a treaty release that contains romamo/treaty#425 (rc38 or later). Labelled blocked until it's on PyPI; then the plan is a treaty bump from rc29 plus the --yes gate, approval.via and cloudfall why output from the plan above, in one PR. The bump from rc29 crosses eight treaty releases, so it may bring changes of its own.

  6. added
    blockedWaits on another issue, here or upstream
    release-blockerHolds the release lanes the policy names until closed
    on Oct 8, 2026
  7. shipmill-romamo commented on Oct 8, 2026

    @shipmill-romamo
    Contributor

    Labelled release-blocker and put in the 0.6.0 milestone: the redesigned cloudfall.dev says the gate lives in Cloudfall and an agent can't go around it, and this issue is exactly an agent going around it. Stable 0.6.0 and new rcs wait for the fix; other merges continue.

  8. added this to the 0.6.0 milestone on Oct 8, 2026
  9. shipmill-romamo commented on Oct 8, 2026

    @shipmill-romamo
    Contributor

    Triage: unblocked; implementing option 1 as decided above.

    treaty 1.0.0rc38 is on PyPI (released 2026-10-08 17:44 UTC) and contains romamo/treaty#425 (f10143b, ctx.attest and requires_person). The plan, in one PR:

    • Bump treaty from 1.0.0rc29 to 1.0.0rc38, and fix whatever the eight releases in between change
    • decisions approve: refuse --yes when headless; on a terminal the person types the decision id, through treaty's person-only confirmation
    • Record how the approval arrived (approval.via) in the decision record, and show it in cloudfall why; records written before it read as via: unknown
    • CLI tests for each path: terminal confirmed, terminal mistyped, headless refused

    I'll link the PR here.

  10. shipmill-romamo commented on Oct 8, 2026

    @shipmill-romamo
    Contributor

    Draft PR: #37. It bumps treaty to rc38, makes decisions approve require a person to type the decision id at a terminal (headless exits 4 PERSON_REQUIRED), and records approval.via, shown by cloudfall why.

    Trade-off: treaty reserves --yes on a person-only command, so the preview moves to decisions show. The bump also changes the stream output of dashboard serve and operator run. Both wait on the decision asked on #37.

    operator approve has the same hole and is served over MCP: filed as #35.

  11. shipmill-romamo commented on Oct 9, 2026

    @shipmill-romamo
    Contributor

    Released in v0.6.0rc7.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestrelease-blockerHolds the release lanes the policy names until closed

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions