Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 40 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,30 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

# The dependency budget is one package, and for the length of epic #105 it
# is briefly two: `cryptography` backs the encrypted store (#107), and
# PyYAML leaves when runs read from the store (#109). If this line grows a
# third package, the budget was breached -- that is not a routine change.
# The dependency budget is one package: `cryptography`, which backs the
# encrypted store. PyYAML left with #109 -- only the one-time import-yaml
# uses it, and it is installed below for that job alone. If this line
# grows a second package, the budget was breached -- that is not a
# routine change.
- name: Install dependencies
run: python3 -m pip install --quiet pyyaml cryptography
run: python3 -m pip install --quiet cryptography

- name: Test suite
- name: Test suite (no PyYAML -- what a run has)
run: python3 -m unittest discover -s tests -v

# --list must resolve every route from config alone. Point the process at
# a dead proxy: if any lane dials out here it fails loudly, rather than
# silently costing money the first time someone adds a real key.
# --config is used rather than seeding $HOME so the check cannot be
# affected by, or affect, a real config on the runner.
- name: '--list makes no network calls'
# The import-yaml tests skip without PyYAML, so run the suite once more
# with it, on one version, to keep the migration path covered.
- name: Test suite with PyYAML (import-yaml)
if: matrix.python-version == '3.14'
run: |
HTTP_PROXY=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 \
ALL_PROXY=http://127.0.0.1:9 \
./roundtable --config tests/fixtures/ci-config.yaml --list
python3 -m pip install --quiet pyyaml
python3 -m unittest discover -s tests
python3 -m pip uninstall --quiet --yes pyyaml

# A throwaway key file stands in for `pass`, which the runner does not have.
- name: 'Store: db init, migrate twice, doctor'
# The store is built in $RUNNER_TEMP so the check cannot be affected by,
# or affect, a real store on the runner.
- name: 'Store: db init, migrate twice, doctor, import the CI roster'
run: |
export ROUNDTABLE_DB_KEY_FILE="$RUNNER_TEMP/db.key" XDG_DATA_HOME="$RUNNER_TEMP/data"
./roundtable db init
Expand All @@ -54,11 +55,29 @@ jobs:
./roundtable db import tests/fixtures/ci-roster.json --replace
./roundtable lanes list
./roundtable db export | diff - tests/fixtures/ci-roster.json
./roundtable import-yaml FlatlineRoundtable.yaml.example --dry-run
./roundtable db doctor

# --list must resolve every route from the store alone. Point the process
# at a dead proxy: if any lane dials out here it fails loudly, rather than
# silently costing money the first time someone adds a real key.
- name: '--list makes no network calls'
run: |
export ROUNDTABLE_DB_KEY_FILE="$RUNNER_TEMP/db.key" XDG_DATA_HOME="$RUNNER_TEMP/data"
HTTP_PROXY=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 \
ALL_PROXY=http://127.0.0.1:9 \
./roundtable --list

- name: Shipped example config parses
- name: 'A YAML --config is refused with the import-yaml pointer'
run: |
python3 -c "import yaml; yaml.safe_load(open('FlatlineRoundtable.yaml.example')); print('example config OK')"
# `! cmd` never fails a step under bash -e, so test the status explicitly.
if ./roundtable --config tests/fixtures/legacy-example.yaml --list 2> err.txt; then
echo "a YAML --config was accepted"; exit 1
fi
grep -q "roundtable import-yaml" err.txt

- name: 'No PyYAML on the run path'
run: |
test "$(grep -cE '^\s*(import yaml|from yaml)' roundtable)" -eq 1

# Opt-in: answers "is the shape still right TODAY", which the golden fixtures
# cannot -- they answer "is it still what it was when captured". Allowed to
Expand All @@ -73,8 +92,8 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install PyYAML
run: python3 -m pip install --quiet pyyaml
- name: Install dependencies
run: python3 -m pip install --quiet cryptography
- name: Compare a live response against the fixture contract
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
Expand Down
13 changes: 9 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
# The real config never enters the repo. It lives at
# ~/.config/flatline-roundtable/FlatlineRoundtable.yaml — this entry is a second
# belt in case someone copies it here anyway. It holds internal hostnames and
# pass entry names; the keys themselves are only ever in `pass`.
# The store never enters the repo. It lives at
# ~/.local/share/flatline-roundtable/roundtable.db; these entries are a second
# belt in case someone points --config here. Its secrets are encrypted, but it
# still holds internal hostnames and secret names.
*.db
*.db-wal
*.db-shm
backups/
# The retired YAML config, if an old copy is dropped here before import-yaml.
FlatlineRoundtable.yaml

# transcripts, if anyone points --config at a repo-local run
Expand Down
17 changes: 9 additions & 8 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ and vice versa.
`rt.fetch_keys` ([#106](https://github.com/CryptoJones/FlatlineRoundtable/issues/106)) — shipped in PR #113
- [x] Encrypted store: `db init|migrate|doctor` and
`secrets set|check|list|rotate|rm|rekey` ([#107](https://github.com/CryptoJones/FlatlineRoundtable/issues/107)) — shipped in PR #114
- [ ] Lane config in the store: `lanes`, `defaults`, `globals`, `lane_versions`,
`db export|import`, `import-yaml` ([#108](https://github.com/CryptoJones/FlatlineRoundtable/issues/108)) — on feat/sqlite-store-lane-config
- [x] Lane config in the store: `lanes`, `defaults`, `globals`, `lane_versions`,
`db export|import`, `import-yaml` ([#108](https://github.com/CryptoJones/FlatlineRoundtable/issues/108)) — shipped in PR #123
- [ ] Run from the store: `load_config` reads the DB, `fetch_keys` reads secrets,
YAML retired ([#109](https://github.com/CryptoJones/FlatlineRoundtable/issues/109))
YAML retired ([#109](https://github.com/CryptoJones/FlatlineRoundtable/issues/109)) — on feat/sqlite-store-run-from-store
- [ ] `db backup|restore` and the telesto restic job ([#110](https://github.com/CryptoJones/FlatlineRoundtable/issues/110))
- [ ] Fleet rollout notes and the Python 3.11 floor ([#111](https://github.com/CryptoJones/FlatlineRoundtable/issues/111))

Expand Down Expand Up @@ -162,16 +162,17 @@ stub.
- [ ] **Orphan kill:** point a `cli` lane at a hanging command with a short
timeout; confirm it fails cleanly and `pgrep` finds no survivor.
*Automated:* `test_hung_lane_is_killed_and_leaves_no_orphan`.
- [ ] **Missing key:** point a lane at a nonexistent `pass` entry; confirm it
fails loudly naming the entry, and never calls unauthenticated.
*Automated:* `test_missing_pass_entry_aborts_the_run`,
`test_absent_pass_binary_aborts_the_run`.
- [ ] **Missing key:** point a lane at a secret the store does not hold; confirm
it fails loudly naming the secret, and never calls unauthenticated.
*Automated:* `test_missing_secret_aborts_the_run`,
`test_no_db_key_and_no_pass_aborts_the_run`,
`test_a_store_secret_reaches_the_authorization_header`.
- [ ] **No key in argv:** during a run, `ps auxww` shows no key. *(human — reads
the live process table; keys are held in memory and set as headers only.)*
- [ ] **No key in output:** transcript and `--json` contain no `Authorization`
value. *Partly automated:* `test_fixtures_carry_no_credentials` scans the
golden fixtures; confirm by hand against a real transcript after a run.
- [ ] `git check-ignore -v FlatlineRoundtable.yaml` confirms the real config
- [ ] `git check-ignore -v roundtable.db roundtable.db-wal` confirms a store
cannot be committed; `git status --porcelain` is clean after a run. *(human)*
- [ ] **Store secrets never in plaintext:** `secrets set X --stdin`, then the
`.db`, `-wal` and `-shm` bytes do not contain the value; a positional value
Expand Down
36 changes: 24 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,15 @@ a child process tree. A hung CLI — or one sitting at a login prompt — surviv
as an orphan. Orphaned lanes billing forever are the exact failure this project
was built to eliminate.

**Keys fetched once, before fan-out.** Concurrent `pass show` calls against one
`gpg-agent` either serialize behind a single pinentry or storm and fail in a
non-tty context. Resolve them while still single-threaded so one clear error
replaces N identical ones.
**Keys fetched once, before fan-out.** The DB key comes from `pass`, and
concurrent `pass show` calls against one `gpg-agent` either serialize behind a
single pinentry or storm and fail in a non-tty context. Resolve every secret
while still single-threaded so one clear error replaces N identical ones.

**`.splitlines()[0]` on `pass` output.** `pass` returns the whole file; the
secret is the first line. Using the blob puts trailing metadata into an
`Authorization` header.
**`.splitlines()[0]` on a stored value.** `pass` returns the whole file; the
secret is the first line. `secrets set` and `import-yaml --pull-secrets` keep
only that line. Storing the blob puts trailing metadata into an `Authorization`
header.

**Per-vendor concurrency.** A llama.cpp server run with `--parallel 1` serves one
request at a time. Fanning out against it queues every lane past its own timeout.
Expand All @@ -67,11 +68,22 @@ every turn precisely so a sequential run that dies keeps what was said.

## Secrets

No key value goes in a config file, this repo, `argv`, a transcript, or `--json`
output. Config names a `pass` entry; the key is read into memory only while that
lane is being queried. The real config lives at
`~/.config/flatline-roundtable/`, deliberately outside the worktree —
`.gitignore` is not a security boundary.
No key value goes in this repo, `argv`, a transcript, or `--json` output, and
the store never holds one in plaintext. A lane names a secret; its value is
AES-256-GCM-encrypted in the store and decrypted into memory only for the run.
The DB key lives in `pass` (`flatline-roundtable/db-key`), never beside the
store. The store lives at `~/.local/share/flatline-roundtable/roundtable.db`,
deliberately outside the worktree — `.gitignore` is not a security boundary.

**A store failure must never fail a paid run.** Reading the roster and secrets
happens before any lane is dispatched, so a broken store stops the run before
it spends. Anything the run path WRITES to the store later (metrics, grades)
must be best-effort: an answer already paid for is delivered even if the write
fails.

**YAML is retired.** The store is the only configuration. PyYAML is imported
only inside `import-yaml`, the one-time move off YAML; do not bring it back to
the run path.

## Scope

Expand Down
58 changes: 32 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,44 +53,46 @@ None of that is possible here:

## Install

Requires Python 3 with PyYAML, and [`pass`](https://www.passwordstore.org/) if
any lane needs an API key.
Requires Python 3.11 or newer with the `cryptography` package, and
[`pass`](https://www.passwordstore.org/), which holds the store's DB key.

```console
$ git clone https://github.com/CryptoJones/FlatlineRoundtable
$ cd FlatlineRoundtable
$ mkdir -p ~/.config/flatline-roundtable
$ cp FlatlineRoundtable.yaml.example ~/.config/flatline-roundtable/FlatlineRoundtable.yaml
$ $EDITOR ~/.config/flatline-roundtable/FlatlineRoundtable.yaml
$ python3 -m pip install --user cryptography
$ ./roundtable db init # the store, and its key in pass
$ ./roundtable db import examples/roster.example.json # or lanes add, or import-yaml
$ ./roundtable --list # shows the roster; makes no network calls
```

Optionally `./install.sh` to expose it as a Claude Code skill.

## Secrets

**No key ever appears in a config file, in this repo, or in a process list.**
**No key ever appears in plaintext on disk, in this repo, or in a process list.**

- A lane names a `pass` entry (`key_entry: openrouter/agent/skeptic`), never a
key.
- Keys are read once, before fan-out, and held in memory only. Never in `argv`,
which is world-readable via `ps`.
- A lane names a secret (`key_entry: openrouter/agent/skeptic`), never a key.
- Secrets live in the store, AES-256-GCM-encrypted. The DB key that unlocks them
lives in the `pass` entry `flatline-roundtable/db-key`, so the store file alone
gives up nothing.
- Keys are decrypted once, before fan-out, and held in memory only. Never in
`argv`, which is world-readable via `ps`.
- Keys never reach the transcript or `--json` output.
- A missing entry fails that lane loudly and names the fix. It never silently
- A missing secret fails the run loudly and names the fix. It never silently
falls back to an unauthenticated call.
- Your real config lives at `~/.config/flatline-roundtable/`, outside the repo,
because `.gitignore` is not a security boundary — `git add -f`, stashes, and
editor backups all defeat it.

**The encrypted store ([#105](https://github.com/CryptoJones/FlatlineRoundtable/issues/105)): admin commands available now, runs switch over in #109.**
**The store ([#105](https://github.com/CryptoJones/FlatlineRoundtable/issues/105)) is the only configuration.**
`roundtable db init` creates `~/.local/share/flatline-roundtable/roundtable.db`
(`0600`, `XDG_DATA_HOME` honoured) and a 32-byte DB key in the `pass` entry
`flatline-roundtable/db-key`. `roundtable secrets set NAME --stdin` stores a
value AES-256-GCM-encrypted under that key. A value typed on the command line is
refused. `secrets check` prints only presence, length and key id. Runs do not
read the store yet; until #109 lands they still use YAML and `pass`.
(`0600`, `XDG_DATA_HOME` honoured) and a 32-byte DB key in `pass`. Runs read
their lanes and secrets from it; `--config` names another store. One `pass show`
per process, for the DB key, replaces one per lane secret.
`roundtable secrets set NAME --stdin` stores a value encrypted under the key. A
value typed on the command line is refused. `secrets check` prints only
presence, length and key id.

The store also holds the lane roster (#108). Move a YAML config in once:
YAML is retired (#109): a `.yaml` `--config` is refused with the command that
moves it in. Move an old YAML config in once, with PyYAML installed for that
one command only:

```sh
roundtable import-yaml ~/.config/flatline-roundtable/FlatlineRoundtable.yaml --dry-run
Expand Down Expand Up @@ -160,7 +162,7 @@ roundtable --list --json # roster as JSON, for tools; no secret r
roundtable --each --run-id ID "..." # tag every transcript of this run with ID
roundtable --synthesize latest:12 # --diff readers over a finished run; no lane re-asked
roundtable --each --revise latest-run # round 2 over the newest run, found by run_id
roundtable --config PATH
roundtable --config DB # another store (default ~/.local/share/flatline-roundtable/roundtable.db)
roundtable --max-spend 0.50 # refuses BEFORE dispatch if the estimate exceeds it
roundtable --panel --diff # report only where the lanes disagree (not with --each)
roundtable --no-transcript
Expand Down Expand Up @@ -206,10 +208,14 @@ declare the same `lineage:`. It is never fatal — a deliberate duplicate is a
legitimate thing to want — but it must not be silent, because it is invisible in
the answers themselves and it falsifies the one claim the tool makes.

Two config files ship with the repo. `FlatlineRoundtable.yaml.example` is the
annotated template — every option, with the reasoning for each. `examples/full-roster.yaml`
is a real thirteen-lane roster, kept because a tuned config is mostly *numbers
you had to measure*, and those are worth reading before you pick your own.
Two rosters ship with the repo, as `db import` files.
`examples/roster.example.json` is a starting roster with one lane of each kind.
`examples/full-roster.json` is a real thirteen-lane roster, kept because a tuned
config is mostly *numbers you had to measure*, and those are worth reading
before you pick your own. The old annotated YAML template, with the reasoning
for every option, is kept as
[`tests/fixtures/legacy-example.yaml`](tests/fixtures/legacy-example.yaml); each
option it documents is a `--set` key now.

Every run writes a transcript to
`~/.local/share/flatline-roundtable/transcripts/`, because answers that exist
Expand Down
Loading
Loading