Skip to content

feat(setup): a credential form, and the line a wizard must not cross to have one - #465

Merged
eaitbrahim merged 1 commit into
mainfrom
feat/d4-credential-form
Aug 20, 2026
Merged

feat(setup): a credential form, and the line a wizard must not cross to have one#465
eaitbrahim merged 1 commit into
mainfrom
feat/d4-credential-form

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Refs #437 (D4), #436 (D3). Milestone: Desktop distribution (#18).

After setup creates a config, a database and a rule library, the next thing a first run needs is a market-data credential — keel fetch authenticates even in paper mode, where no order can be placed. A desktop user has no terminal to type one into, so the browser has to accept it.

That required moving a line I drew myself

The rule was "only MECHANICAL steps may be actions", reasoning that a JUDGEMENT step is the operator's — "a wizard may collect and record them but must never decide them".

But collecting and recording is a write. The rule as written forbade the very thing D4 asks for. It was too strong in the wrong dimension.

StepKind gains OPERATOR_INPUT, split out of JUDGEMENT, because the two are different things:

what it is may a form record it?
JUDGEMENT a decision only a human may make — a Shariah classification, a promotion no
OPERATOR_INPUT a fact only the operator possesses — an API key yes

A form that recorded a judgement would be making a compliance ruling on the operator's behalf, and no amount of "but they clicked it" makes that the same as their having decided it. A wizard can record an API key and could not possibly invent one.

Collapsing the two would have forced one of two bad outcomes: either the browser could record an attestation, or it could never accept a credential — and a desktop user could never configure keel at all.

The pin is stronger, not looser. Actions must cover only MECHANICAL or OPERATOR_INPUT steps; no JUDGEMENT or OFF_VENUE step may ever be one; and an action must declare inputs exactly when its step is OPERATOR_INPUT — so a mechanical action cannot quietly start accepting operator data, nor an operator-input one quietly stop requiring it.

What a secret is not allowed to do

  • Never rendered back into the page, not even on a re-render after failure. Pre-filling a password field puts the secret in the page source, where it survives a screenshot, a view-source and anything that saves the page. A failed submission must be retyped — that is the correct cost.
  • Never in the redirect. The Location carries the step key and nothing else. A value there is a secret in browser history, in the Referer of anything the page later loads, and in any proxy log in between — the whole reason the form is a POST.
  • autocomplete="off", so a password manager does not offer to store an exchange API key as a website login.
  • Only declared fields are forwarded. The form is attacker-shaped input the moment anyone can craft a POST, so an action never receives a key it has no name for.

Both fields are required and neither has a default: an action that could fill in a field the operator left blank is one that could record something they never supplied.

The confirmation is read back through the same resolver a real caller uses, so a .env that shadows what was just stored is reported now rather than at the first fetch that used the other key.

Verified end to end

Against a live server and the real macOS Keychain:

303  config    303  database    303  rules    303  credentials

checklist:  done · operator_input · A market-data credential · stored in the OS keychain
SECRET LEAKED INTO PAGE: False          (checked across every route)
keychain read-back:      source=keychain, matches=True

4069 passed, 3 skipped (7 new). ruff check clean repo-wide, mypy clean over keel + packages.

Next

Market data — the last mechanical step before a paper deployment actually does something. It needs a background job with progress, since a first fetch across the allowlist runs for minutes and a request that blocks that long is not a button.

🤖 Generated with Claude Code

…to have one

After setup creates a config, a database and a rule library, the next thing a first run needs is a
market-data credential -- `keel fetch` authenticates even in paper mode, where no order can be
placed. A desktop user has no terminal to type one into, so the browser has to accept it.

THAT REQUIRED MOVING A LINE I DREW MYSELF, and the move is the substance of this change.

The rule was "only MECHANICAL steps may be actions", with the reasoning that a JUDGEMENT step is
the operator's -- "a wizard may collect and record them but must never decide them". But
collecting and recording IS a write, so the rule as written forbade the very thing D4 asks for. It
was too strong in the wrong dimension.

`StepKind` gains `OPERATOR_INPUT`, split out of `JUDGEMENT`, because the two are different things:

* A JUDGEMENT is a DECISION only a human may make -- a Shariah classification, a promotion. A form
  that recorded one would be making a compliance ruling on the operator's behalf, and no amount of
  "but they clicked it" makes that the same as their having decided it.
* An OPERATOR_INPUT is a FACT only the operator possesses -- an API key. A wizard can record one
  and could not possibly invent one, so a form is safe here in a way it is not there.

Collapsing them would have forced one of two bad outcomes: either the browser could record an
attestation, or it could never accept a credential and a desktop user could never configure keel
at all. The pin is now stronger rather than looser: actions must cover only MECHANICAL or
OPERATOR_INPUT steps, no JUDGEMENT or OFF_VENUE step may ever be one, AND an action must declare
inputs exactly when its step is OPERATOR_INPUT -- so a mechanical action cannot quietly start
accepting operator data, nor an operator-input one quietly stop requiring it.

WHAT A SECRET IS NOT ALLOWED TO DO.

It is never rendered back into the page, not even on a re-render after failure: pre-filling a
password field puts the secret in the page source, where it survives a screenshot, a view-source
and anything that saves the page. A failed submission must be retyped, and that is the correct
cost. The redirect carries the step key and nothing else -- a value in a Location is a secret in
browser history, in the Referer of anything the page later loads, and in any proxy log in between,
which is the whole reason the form is a POST. `autocomplete="off"` keeps a password manager from
offering to store an exchange API key as a website login. And only the fields an action DECLARES
are forwarded: the form is attacker-shaped input the moment anyone can craft a POST, so an action
never receives a key it has no name for.

Both fields are required and neither has a default. An action that could fill in a field the
operator left blank is one that could record something they never supplied.

The confirmation is read back through the same resolver a real caller uses, so a `.env` that
shadows what was just stored is reported now rather than at the first fetch that used the other
key. The checklist reports WHERE the credential comes from for the same reason.

VERIFIED END TO END against a live server and the real macOS Keychain: config, database, rules and
credentials all 303; the checklist then reads "done · operator_input · stored in the OS keychain";
the secret appears in no page, no redirect and no log; and reading it back through
`keel_core.secrets` returns it from the keychain, matching what was submitted.

4069 passed, 3 skipped (7 new). ruff clean repo-wide; mypy clean over keel + packages.

Refs #437, #436, #18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit c447d11 into main Aug 20, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the feat/d4-credential-form branch August 20, 2026 21:53
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