Skip to content

feat(security): delete the vault and the passphrase gate; autonomy becomes a profile choice - #119

Merged
eaitbrahim merged 9 commits into
mainfrom
feat/security-simplification
Jul 21, 2026
Merged

feat(security): delete the vault and the passphrase gate; autonomy becomes a profile choice#119
eaitbrahim merged 9 commits into
mainfrom
feat/security-simplification

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Removes both local security mechanisms from spec §14 and replaces them with one rule: dangerous actions need a human at a terminal; nothing needs a stored secret.

The rails are untouched. All 17 still run first, un-overridable, in every mode. This changes who is asked, never what is allowed. Every claim below has a test.

The vault is gone — and it was already dead code

keel/security/secrets.py (AES-GCM secrets.enc) is deleted, along with the now-unused cryptography dependency.

Worth stating plainly, because it changes how you should read the diff: the vault was never on the live path. data/cb_client.py and cli.py have always loaded credentials via config.load_secrets() from a git-ignored .env; the vault was reachable only through migrate_from_env. Deleting it removes a competing credential path rather than changing how credentials are actually loaded. (PR #115, which would have wired it up, was closed for this reason.)

The passphrase gate is gone

keel/security/authz.py is deleted. Of its four declared dangerous actions, raise_caps and unlock_vault were never used at all — caps are config-file-only, and the vault had no CLI surface. arm_bypass disappears with bypass mode.

That left four commands, all one idea — re-permitting trading after a safety halt: resume, resume-entries, record-flow, reset-hwm. Each now demands a typed yes (not a bare y) and fails closed off a TTY.

The reasoning is coherence. Since #117, placing a real, money-spending order needs only a typed confirmation. Requiring a remembered secret to reset a high-water mark is ceremony without a matching threat model — and the old gate's own docstring conceded it "does not stop an attacker who already holds the OS account". One rule is easier to audit than two.

⚠️ There is deliberately no env-var or flag override for the TTY check. I started to add one as a test seam and rejected it: any such seam is settable from cron and would defeat every fail-closed built on it. Tests patch the predicate instead.

Autonomy is a profile choice, not a config mode

New profile table (schema 6 → 7) holds the user's autonomous flag.

keel autonomy show
keel autonomy on     # typed "yes", terminal required
keel autonomy off    # always allowed, works from a script or cron

auto_trade.mode collapses to paper | confirm, so there are two independent switches rather than one enum conflating both:

mode autonomous result
paper (ignored) simulated, places nothing
confirm false (default) live, asks before every order
confirm true live, places without asking

Four properties, each tested:

  • Read live, never cached_effective_mode re-reads the profile every cycle, so keel autonomy off binds on the next order, not the next restart. (Mirrors rail 14's allowance, moved to the DB for the same reason.)
  • Fails closed — an absent or deleted profile row reads as not-autonomous. A database that never recorded a choice must not imply consent.
  • Enforced in-process — the check lives inside run_once, not only at the CLI, so a caller driving run_once directly cannot obtain autonomy the CLI would refuse.
  • Not scriptable to enableautonomy on requires a terminal. off deliberately does not: reducing risk must never be obstructed.

Autonomy does not clear safety halts

The one place I deliberately did not honour "autonomous means no confirmation". The four halt-releasing commands still demand a human with autonomy fully on — there is a test named for it. "Trade without asking me" and "un-stick your own drawdown breaker" are different powers; a breaker that can reset itself is not a breaker.

⚠️ Breaking

A config carrying mode: bypass or bypass_arm_ttl_sec now fails to load, naming the key and telling you what to do (mode: confirm plus keel autonomy on). Previously an unknown mode degraded silently to confirm. A config saying bypass was explicitly asking for autonomy, and quietly reinterpreting that request — in either direction — is worse than stopping. Run keel migrate to add the profile table; delete any secrets.enc/authz.json (both are simply ignored now).

Also

The go-live runbook is rewritten (supersedes #116, which documents the arm-bypass/passphrase dance that no longer exists) and is honest that its purpose is proving the plumbing — place_order has still never run against the real API — not profit. Spec §14 is marked BUILT THEN REMOVED with the reasoning, so neither mechanism gets re-proposed from the old text.

Verification

1277 tests pass, ruff clean. Grep-proven: zero live references to authz, save_vault, arm_bypass, is_bypass_armed, bypass_arm_ttl_sec or secrets.enc anywhere in keel/, packages/, tests/ or scripts/.

Smoke-run against the real CLI, not just unit tests: a v6 database migrating 6 → 7; autonomy on piped (refused — "needs an interactive terminal"); autonomy off piped (allowed); resume piped (refused); and a mode: bypass config failing to load with the key named.

Design: docs/superpowers/specs/2026-07-21-security-simplification-design.md.

eaitbrahim and others added 6 commits July 21, 2026 18:49
Delete the vault (already dead code) and the passphrase gate; autonomy
becomes a live-read profile choice. Rails unchanged -- this changes who is
asked, never what is allowed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get_profile() FAILS CLOSED -- an absent row reports autonomous=False, because
the safe reading of 'no record' is that the user never opted into unattended
trading. Single-row by CHECK(id=1); user_id is the seam for hosted multi-user.
Retires the arm_bypass/is_bypass_armed/disarm_bypass token API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… a profile choice

The vault was already dead code: cb_client/cli load credentials from .env via
config.load_secrets, and it was reachable only through migrate_from_env. Deleting
it removes a competing credential path rather than changing how creds are loaded.
Drops the now-unused cryptography dependency.

The passphrase gate is replaced by one rule -- dangerous actions need a human at a
terminal, nothing needs a stored secret. Once placing a money-spending order needs
only a typed confirmation, a remembered secret to reset a high-water mark is
ceremony without a matching threat model. The four halt-releasing commands
(resume, resume-entries, record-flow, reset-hwm) now demand a typed 'yes' and FAIL
CLOSED off a TTY. There is deliberately no env/flag override for that TTY check:
any such seam would be settable from cron and would defeat the fail-closed.

Autonomy moves from a config mode + armed token to a live-read profile choice.
_effective_mode returns 'autonomous' only when the config is live AND the profile
says so, is re-read every cycle (so 'autonomy off' binds on the NEXT order), lives
inside run_once so an in-process caller cannot obtain what the CLI would refuse,
and fails closed on an absent row. Rails are untouched throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mode is now paper|confirm and an unknown value RAISES naming the key, where it
previously degraded silently. A config saying 'mode: bypass' was explicitly
asking for autonomy, and autonomy is now a profile choice -- quietly
reinterpreting that request in either direction is worse than stopping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The runbook documented the arm-bypass/passphrase dance that no longer exists.
Rewritten against the real flow, and honest that its purpose is proving the
plumbing (place_order has never run against the real API), not profit.
Supersedes PR #116. Marks spec §14's vault + passphrase gate as BUILT THEN
REMOVED, with the reasoning, so neither is re-proposed from the old text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes the last descriptions of the vault, the passphrase gate and the
arm-bypass token -- no live references to any of them remain anywhere in
keel/, packages/, tests/ or scripts/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@eaitbrahim eaitbrahim added enhancement New feature or request rails Un-overridable safety rail / guard (Compliance & rails) labels Jul 21, 2026
eaitbrahim and others added 3 commits July 21, 2026 19:31
… fail-closed

Addresses an independent review of this PR. Two blocking findings:

1. `keel withdrawals attest --enabled` RELEASES rail 17's entry halt and was
   ungated and scriptable. Its own docstring justified that with 'the confirm
   gate and the bypass-arm token still sit in front of it' -- this PR deletes
   the token, and with autonomy on the confirm gate is gone too, so cron could
   have re-permitted live entries with no human. Now gated like the other four;
   --suspended stays ungated (it only reduces capability).

2. Autonomy replaced a TTL-bounded authorization with an unbounded one -- a
   forgotten 'autonomy on' would grant unattended trading forever, where the old
   arm token expired in an hour. Adds optional 'keel autonomy on --for-hours N'
   (Profile.is_autonomous honours it); the default stays durable per the
   requirement, but 'on' now says plainly when no expiry is set, and 'show'
   reports remaining time or that it lapsed. profile is new on this branch, so
   the column lands in v7 rather than needing a v8.

Also: get_profile catches sqlite3.Error so it actually implements the
fail-closed contract it documents; _interactive_confirm uses the single
_is_interactive predicate; disclaimer on all autonomy commands; record-flow
shows the amount being confirmed; stale docstrings corrected; and config.yaml
no longer implies auto_trade.enabled is a kill-switch (it is read by nothing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ow-ups

Second review round. The expiry column was added to v7's CREATE TABLE IF NOT
EXISTS, which does nothing for a database already stamped 7 -- it kept the old
three-column table forever, so a recorded choice read back as OFF and
'keel autonomy off' (the DE-RISKING command) died on a missing column. Adds an
idempotent v8 ALTER TABLE step. Reachable only on a developer database from this
unmerged branch, but the command that reduces risk must never be the one that
crashes.

Also from the review:
- end-to-end test that an EXPIRED profile yields mode=confirm out of run_once;
  the enforcement chain was previously only tested in pieces, so dropping the
  now_ts argument would have silently un-bounded autonomy again
- get_profile logs when the profile is unreadable instead of swallowing it
  indistinguishably from 'the user never opted in'
- --for-hours rejects 0/negative/inf/nan/overflow (0 previously wrote an
  already-lapsed row while printing 'autonomy ON until ...')
- the halt-releasing inventory is FIVE commands, not four -- corrected in the
  CLI docstring and the runbook, which are what an operator actually reads
- runbook: autonomy off binds on the next CYCLE, not the next order (up to 15
  minutes at interval_sec 900) -- use 'keel kill' to stop immediately; and
  --for-hours is now documented where a supervised session is described

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…order'

Third review round, all non-blocking:
- --for-hours 1e-9 rounded to a zero-length window: it stored an already-lapsed
  row while printing 'autonomy ON until ...'. Same misleading message the last
  round closed for 0, just past the boundary. Minimum is now one second.
- Four code docstrings still promised 'autonomy off takes effect on the next
  ORDER'; it is the next CYCLE, and agent.py contradicted itself in a single
  sentence. This is the one operational fact that matters when someone is
  trying to stop live trading, so they now also point at 'keel kill'.
- Repository.profile_readable() lets 'autonomy show' tell a human the stored
  setting is UNKNOWN rather than printing a reassuring 'off'. Note that
  _open_repo's migrate() heals a merely missing table, so this covers damage
  migrate cannot -- the test lives at repository level for that reason.
- logging.getLogger(__name__) per codebase convention; stale section banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit 7560d6b into main Jul 21, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the feat/security-simplification branch July 21, 2026 23:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request rails Un-overridable safety rail / guard (Compliance & rails)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant