Skip to content

fix: a person's approval needs a registered approver key's signature - #1486

Open
abrichr wants to merge 1 commit into
mainfrom
fix/signed-human-approval
Open

abrichr wants to merge 1 commit into
mainfrom
fix/signed-human-approval

Conversation

@abrichr

@abrichr abrichr commented Oct 21, 2026

Copy link
Copy Markdown
Member

Problem

A gated save counted as approved by a person when the answer file said so. Any one of these was enough:

  • item-approval approve --decided-by human --unauthenticated;
  • a claimed --approver-id X --auth-method cloud_aal2;
  • an answer file of the right shape written straight into the run directory.

The run accepted all of these. Any process that could run the CLI or write the run directory, including the agent that started the run, could approve its own gated save. The durable approve recorded whoever the OS user was (getpass.getuser()) as the approver.

Fix

A person's approval now needs a verified signature:

  • Item approvals: ItemApprovalAnswer carries a HumanAttestation, an Ed25519 signature over the answer by a key registered under a new approvers role in principals.json (jit register --role approver). The signature must use the route's own method: desktop_key for Desktop's Run it, portal_key for the decision portal. The run verifies it for every answer it reads (approval_mode/human_attestation.py).
  • Claims are not proof:
    • --decided-by human and --unauthenticated on their own are refused (human_answer_unsigned).
    • A claimed Cloud sign-in is refused (auth_method_unverified), because nothing here can verify it.
    • Self-approval is refused whatever the answer claims.
  • Fail closed: each of these is a refusal:
    • no key directory;
    • an unregistered key, or an agent's key (one key, one role);
    • a changed answer;
    • the portal key answering for Desktop.
  • Durable approve: openadapt-flow approve --approver-key signs the ApprovalRecord and names the key's registered approver. getpass is gone. resume refuses a stored approval.json without a valid signature. Item approvals and business decisions keep their own signed authority.
  • Decision portal: it signs with OPENADAPT_PORTAL_APPROVER_KEY and records no answer without it.
  • Desktop: Run it answers with Desktop's approver key; the passphrase goes only into the child environment. With no registered key it sends nothing and shows the exact one-time terminal commands. openadapt-desktop approver-key setup creates the keys. In-app registration (Keychain, Touch ID) is a separate follow-up.
  • Demos that play a person register a throwaway key in their own temporary key directory.

Delegated (agent or system) answers under a person-signed grant are unchanged.

Flows that changed

  • CLI: item-approval approve|decline needs --approver-key for a person, and approve (durable) needs --approver-key.
  • Portal: phone save-approvals need OPENADAPT_PORTAL_APPROVER_KEY on the runner.
  • Desktop: Run it needs its approver key registered once.
  • Tests that asserted the old behaviour now assert refusal:
    • test_approval_mode_item_approval (claimed cloud_aal2);
    • test_approval_delegated (claimed Cloud, unsigned copy);
    • test_approval_mode_cli::test_a_person_approves_once;
    • test_cli_new_commands::test_approve_flips_status_and_is_idempotent (no OS-user approve);
    • test_desktop_check_it_cli_mockmed.

Tests

  • New test_human_approval_verified.py:
    • an agent passing --decided-by human --unauthenticated is refused;
    • an unsigned answer written straight to the run is refused;
    • a claimed cloud_aal2 is refused, with and without a key;
    • a properly signed answer passes;
    • a changed signed answer is refused;
    • the portal key can't answer for Desktop;
    • an agent key is not an approver;
    • self-approval is refused;
    • no registry means no answer;
    • the durable approve needs the key and no longer trusts the OS user;
    • resume refuses an unsigned stored approval.
  • New test_console_save_approvals::test_a_portal_without_an_approver_key_records_no_answer.
  • Local results:
    • Flow, every file the CI shards run: 10,695 passed, 1 failed. The failure is test_layout_drift::test_real_render[kw3-font_size_changed], a local font-rendering test that fails the same way on main.
    • Agent: 532 passed. Two failures need an installed openadapt-agent entry point and don't involve approvals.
    • Desktop: 1,277 passed.
    • Ruff, mypy (561 files, plus the strict safety path) and check_release_consistency.py are clean.
    • gitleaks git --log-opts=origin/main..HEAD: no leaks.

Known follow-ups

  • desktop-release-intent may flag that this touches Desktop without a version bump. A bump would auto-publish Desktop, so it's left to the release decision.
  • Durable resume reads the registry from the default key directory. An approve --key-dir override isn't carried to resume.

🤖 Generated with Claude Code

A gated save counted as approved by a person when the answer said so.
--decided-by human with --unauthenticated, or a claimed --auth-method, was
enough, and the run accepted any answer file of that shape. Any process
that could run the CLI or write the run directory, such as the agent that
started the run, could approve its own save. The durable approve named
whoever the OS user was (getpass).

A person's answer now counts only with a HumanAttestation: an Ed25519
signature over the answer by a key registered under the new `approvers`
role in principals.json (jit register --role approver), using the route's
own method (desktop_key for Desktop's Run it, portal_key for the decision
portal). The run verifies it on every answer. A claimed Cloud sign-in is
refused because nothing here can verify it, and self-approval is refused
whatever the answer claims. No key directory, an unregistered key, an
agent's key, or a changed answer is a refusal.

- The durable approve signs its ApprovalRecord with --approver-key and
  names the key's registered approver; resume refuses a stored approval
  without a valid signature (item approvals and business decisions keep
  their own signed authority).
- The decision portal signs with OPENADAPT_PORTAL_APPROVER_KEY and records
  no answer without it.
- Desktop's Run it answers with its approver key and refuses, giving the
  exact one-time terminal setup commands, when none is registered.
  `openadapt-desktop approver-key setup` creates the keys.
- Demos that play a person register a throwaway key in their own
  temporary key directory.
- Tests that answered as a person now sign; tests that asserted the old
  claims were accepted now assert refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abrichr
abrichr enabled auto-merge October 21, 2026 04:41

This branch has not been deployed

No deployments
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.

1 participant