From bbd1796b223bbc4b5e82f569419f74ec85acd4f2 Mon Sep 17 00:00:00 2001 From: fredaime Date: Tue, 29 Sep 2026 02:10:00 +0000 Subject: [PATCH] release: 0.3.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **`instrument verify` says how a run ended when the program raised, too.** The harness said its own ending only for a program that returned, so a program that ended on an exception before a line of it was seen — a script that does not compile, a module whose bytecode another Python wrote — left no ending behind, and the command answered `4` and « the verification did not run to a conclusion »: « the control plane could not be asked », about a plane that had answered. It now answers `7`, « the verifier never saw the program's own code start », naming the exception the program ended on; the program's own traceback is still printed. For the same reason, a program that caught the abort of an unreadable chain and then raised an exception of its own now gets the chain's ending (`4`, the transport's own problem code and sentence) rather than the generic one. - The contract and the boundary move to 0.3.1, released with this client. - A `python` the shell cannot find is refused as before (64), and the refusal now names the `python3` on the same PATH. - Every program the README and the quickstart run is spelled `python3 …`; the quickstart starts the daemon again before section 7, shows a closed epoch verified, and says how long a rejection stands. Signed-off-by: fredaime --- CHANGELOG.md | 19 ++ QUICKSTART.md | 248 +++++++++++++++------ README.md | 34 +-- docs/PACKS.md | 22 +- pyproject.toml | 6 +- src/sayfirst_cli/instrument/harness.py | 77 ++++++- src/sayfirst_cli/instrument/interpreter.py | 15 +- tests/test_instrument_verify.py | 145 +++++++++++- tests/test_interpreter_target.py | 56 +++++ tests/test_quickstart_names_what_exists.py | 24 ++ 10 files changed, 532 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d4bdc2..d8901bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ declares; a release with no section fails the gate. ## Unreleased +## 0.3.1 + +- **`instrument verify` says how a run ended when the program raised, too.** The harness said + its own ending only for a program that returned, so a program that ended on an exception before + a line of it was seen — a script that does not compile, a module whose bytecode another Python + wrote — left no ending behind, and the command answered `4` and « the verification did not run + to a conclusion »: « the control plane could not be asked », about a plane that had answered. + It now answers `7`, « the verifier never saw the program's own code start », naming the + exception the program ended on; the program's own traceback is still printed. For the same + reason, a program that caught the abort of an unreadable chain and then raised an exception of + its own now gets the chain's ending (`4`, the transport's own problem code and sentence) rather + than the generic one. +- The contract and the boundary move to 0.3.1, released with this client. +- A `python` the shell cannot find is refused as before (64), and the refusal now names + the `python3` on the same PATH. +- Every program the README and the quickstart run is spelled `python3 …`; the + quickstart starts the daemon again before section 7, shows a closed epoch verified, + and says how long a rejection stands. + ## 0.3.0 - **`--socket` is optional for a per-user profile**, on every verb that opens a connection. Given diff --git a/QUICKSTART.md b/QUICKSTART.md index 45fb729..13f06cb 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -7,13 +7,16 @@ Python program you already have: ```console $ uv tool install sayfirst-cli --with-executables-from sayfirst-control-plane --with-executables-from sayfirstd $ sayfirst-daemon up --quickstart -$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +$ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py ``` Everything on this page was run, in this order, before it was written down: the commands are pasted from that run, and so are their answers. The run installed -0.3.0 from the Python index with the first command, in a shell with neither -repository on any path, under a home directory made for it; +0.3.1 with the first command from wheels built from the two release trees, +given `--no-index` (the walk ran before 0.3.1 reached the index; « From +checkouts instead » below is that install), on a PATH with `python3` and no +`python`, in a shell with neither repository on any path, under a home +directory made for it; its paths are written here the way they read under an ordinary account (`~`, and `/run/user/1000` for the runtime directory). Your identifiers, timestamps and hashes will differ; the shapes will not. @@ -22,21 +25,28 @@ hashes will differ; the shapes will not. - Linux or macOS. The control plane listens on a local socket and reads who is calling from the kernel; there is no port and nothing that takes a token. -- Python 3.12, 3.13 or 3.14, and [`uv`](https://docs.astral.sh/uv/). The walk - used uv 0.12. -- A home directory that only you can write. The daemon refuses to put its - socket below a directory a group can write and that is not sticky - (`socket_directory_unprotected`), and it says so instead of starting. +- Python 3.12, 3.13 or 3.14 as `python3` on your PATH (Linux distributions + and macOS name it that; inside an activated virtual environment `python` + works too), and [`uv`](https://docs.astral.sh/uv/). The walk used + uv 0.12. +- A home directory that only you can write. The daemon refuses a directory a + group can write and that is not sticky: below the socket as + `socket_directory_unprotected`, and above the policy as + `policy_unavailable_at_start … exposed: group_write`; either way it says so + instead of starting. ## 1. Install ```console $ uv tool install sayfirst-cli --with-executables-from sayfirst-control-plane --with-executables-from sayfirstd - + sayfirst-boundary==0.3.0 - + sayfirst-cli==0.3.0 - + sayfirst-contract==0.3.0 - + sayfirst-control-plane==0.3.0 - + sayfirstd==0.3.0 +Resolved 5 packages in 3ms +Prepared 5 packages in 6ms +Installed 5 packages in 1ms + + sayfirst-boundary==0.3.1 + + sayfirst-cli==0.3.1 + + sayfirst-contract==0.3.1 + + sayfirst-control-plane==0.3.1 + + sayfirstd==0.3.1 Installed 1 executable from `sayfirst-control-plane`: sayfirst-daemon Installed 1 executable from `sayfirstd`: sayfirstd Installed 1 executable: sayfirst @@ -79,7 +89,7 @@ grade re-evaluation interval: 30 seconds privacy provider: none (captured content is recorded as given) evidence emission: delivering log: ~/.sayfirst/quickstart/daemon.log -pid: 2174481 +pid: 741184 wrote: ~/.sayfirst/quickstart/policy.toml wrote: ~/.sayfirst/quickstart/daemon.toml stop: sayfirst-daemon down @@ -97,13 +107,15 @@ Everything lives in one private directory (`0700`, files `0600`): ```console $ ls -la ~/.sayfirst/quickstart -drwx------ 3 you you 160 . --rw------- 1 you you 0 daemon.lock --rw------- 1 you you 75 daemon.log --rw------- 1 you you 259 daemon.run.json --rw------- 1 you you 596 daemon.toml -drwx------ 5 you you 120 evidence --rw------- 1 you you 3008 policy.toml +total 16 +drwx------ 3 you you 160 Sep 29 01:06 . +drwx------ 3 you you 60 Sep 29 01:06 .. +-rw------- 1 you you 0 Sep 29 01:06 daemon.lock +-rw------- 1 you you 76 Sep 29 01:06 daemon.log +-rw------- 1 you you 260 Sep 29 01:06 daemon.run.json +-rw------- 1 you you 598 Sep 29 01:06 daemon.toml +drwx------ 5 you you 120 Sep 29 01:06 evidence +-rw------- 1 you you 3008 Sep 29 01:06 policy.toml ``` `daemon.run.json` is what `up` knows about the daemon it started: its pid, the @@ -139,7 +151,7 @@ subprocess.run(["echo", "hello"], check=True) ``` ```console -$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +$ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py hello ``` @@ -159,27 +171,51 @@ that. Three things in that line are worth a sentence each. one name. Nothing is searched for, and whoever answers is still verified to be your own account's process before a byte is sent. `--socket PATH` overrides it, and system mode always names it. -- **`python` means your `python`.** The program is handed to the interpreter +- **`python3` means your `python3`.** The program is handed to the interpreter you named, found the way your shell finds it, with the boundary installed in that process — so a program living in a project's environment keeps its own - dependencies: + dependencies. Here `~/project` has an environment of its own + (`uv venv`, then `uv pip install humanize`) holding a package the `sayfirst` + tool does not, and `real_agent.py` uses it: + +```python +import sys + +import humanize + +print("hello from the project's own interpreter") +print("running under", sys.prefix) +print("imported:", humanize.naturalsize(1_000_000)) +``` + + In `~/project`, with its environment activated, `python3` is that + environment's: ```console -$ sayfirst instrument run --pack subprocess --scope local -- python real_agent.py +$ . .venv/bin/activate +$ sayfirst instrument run --pack subprocess --scope local -- python3 real_agent.py hello from the project's own interpreter running under ~/project/.venv -imported: a dependency only the project has +imported: 1.0 MB ``` - An agent started as a console script rather than as `python …` is handed + Without the activation the same command finds the system `python3`, and the + program stops at its import (`ModuleNotFoundError: No module named 'humanize'`). + + An agent started as a console script rather than as `python3 …` is handed over the same way: `-- ./myagent` reads the executable's shebang (`#!…/.venv/bin/python`) and runs it under that interpreter. Named without an interpreter (`-- my_agent.py`, or `-- -m package`), a program runs inside the interpreter that carries `sayfirst` itself, which as a `uv` tool has none of your project's packages. A runner in that place — `-- uv run app.py` — is refused, because it would pick an interpreter the boundary is not in; name the - interpreter instead. The named interpreter has to be Python 3.12 or later, and - interpreter options (`python -u …`) are not carried. + interpreter instead. The named interpreter has to be Python 3.12 or later. An + option after the interpreter's name is refused (`-- python3 -u real_agent.py` + exits `64`: « found no file to run at '-u' »); an option the interpreter also + reads from the environment reaches it that way — + `PYTHONUNBUFFERED=1 sayfirst instrument run …` runs the program unbuffered. + A `python` the shell cannot find is refused (`64`), and the refusal says the + same PATH has a `python3`. What the control plane recorded, read back from it: @@ -192,12 +228,12 @@ grade re-evaluation interval: 30 seconds privacy provider: none (captured content is recorded as given) evidence emission: delivering $ sayfirst evidence history --scope local --from 1 --all -1 composition daemon 9d91e37895f3f3ec5ddd99313eba86113a35d3e54926fa3364b1a2910993a35b -2 grade 8af0e4a3-1e36-4956-b3e3-945061fcb86d f81ccada8136e2565f2223af93da4f443113c2897f6797048313968ccbef81e5 -3 grade cbe53213-4868-457a-a5bb-5703741ff248 2a02c37627ae178fa795d58cf76bf337905cc2f430938ae6b3aa2fb941779d55 -4 grade cc16640d-1aa0-4c92-82ba-28e8aba91983 3848a18e49c31896304fd5114ced98de3a250f4fce83e249c6053716d95359b4 -5 effect cc16640d-1aa0-4c92-82ba-28e8aba91983 c96ec3c3fbdf3cb861b93fca81887ad69a6ed151805b6e04dca9ef82405fdea1 -… +1 composition daemon b7e07cc5149332bda660b2bfd874409cc3c43c3fcc545bcdfe434eeca08d7992 +2 grade 5ca3ce19-2d93-44de-839a-a25148efacc4 8e500b2d2f634f07ed10e70b769b0d170821bd07d9121c5fcdabbb914fdf4d66 +3 grade bc12e161-76be-4939-80d2-711c7ba145d8 614359e106f698383cc7da518ebd5714be580e70acf0ae9e3b9141b40a83ecb4 +4 effect bc12e161-76be-4939-80d2-711c7ba145d8 effeb84e48ae71368bd796198d1a49c256c4f7c381f72d11d3e7dc5567355d5e +5 grade 8abbfb3c-a3b1-4ca1-8f74-fc732ffa7caa c5a7e9be2d2af3fd0dbb3d691caa7a4019a06dcf1dea34417acece0cb91753ea +6 grade 8af05baa-2b94-47f9-acdc-3d9dbd51850e 55e018d5ba3cfd56f7e812351b3a6762bb3006e0209eaf30a27cb722c008167a next_from: none ``` @@ -210,10 +246,10 @@ and change one word — `outcome = "allow"` to `outcome = "deny"`. Save. Nothing is restarted; the daemon reads the file when it decides. ```console -$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +$ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py Traceback (most recent call last): … -sayfirst_boundary.errors.Denied: denied: process.spawn (policy_denies, 3f25749a-3e1a-4f73-b7e8-34bba22d0010) +sayfirst_boundary.errors.Denied: denied: process.spawn (policy_denies, 47339de8-e6c3-4b2f-8c91-7eaa6a53aa26) $ echo $? 1 ``` @@ -228,10 +264,10 @@ suspension or an unreachable control plane as a denial too. Now `outcome = "suspend"`: ```console -$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +$ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py Traceback (most recent call last): … -sayfirst_boundary.errors.Suspended: suspended: process.spawn awaits approval 22f85fcb-fdab-4802-bde7-d26eed599fb7 +sayfirst_boundary.errors.Suspended: suspended: process.spawn awaits approval 93684c01-f9e8-4f27-9ad8-6c2be85306c5 $ echo $? 5 ``` @@ -239,38 +275,41 @@ $ echo $? Nothing ran, and a person is being waited for. Read the wait, then end it: ```console -$ sayfirst approvals show --approval 22f85fcb-fdab-4802-bde7-d26eed599fb7 --scope local -approval: 22f85fcb-fdab-4802-bde7-d26eed599fb7 -decision: 46c2447e-ac2a-40d8-bfc6-87340d21dd0f +$ sayfirst approvals show --approval 93684c01-f9e8-4f27-9ad8-6c2be85306c5 --scope local +approval: 93684c01-f9e8-4f27-9ad8-6c2be85306c5 +decision: ace43a8a-88ad-4907-8e91-4e978af85604 state: pending -requested_at: 2026-09-23T18:58:32.922810Z -deadline: 2026-09-23T19:03:32.922810Z +requested_at: 2026-09-28T23:06:51.035245Z +deadline: 2026-09-28T23:11:51.035245Z resolved_at: not stated reason: not stated -$ sayfirst approvals approve --approval 22f85fcb-fdab-4802-bde7-d26eed599fb7 --scope local --reason "checked by hand" -approval: 22f85fcb-fdab-4802-bde7-d26eed599fb7 -decision: 46c2447e-ac2a-40d8-bfc6-87340d21dd0f +$ sayfirst approvals approve --approval 93684c01-f9e8-4f27-9ad8-6c2be85306c5 --scope local --reason "checked by hand" +approval: 93684c01-f9e8-4f27-9ad8-6c2be85306c5 +decision: ace43a8a-88ad-4907-8e91-4e978af85604 state: approved -requested_at: 2026-09-23T18:58:32.922810Z -deadline: 2026-09-23T19:03:32.922810Z -resolved_at: 2026-09-23T18:58:33.083390Z +requested_at: 2026-09-28T23:06:51.035245Z +deadline: 2026-09-28T23:11:51.035245Z +resolved_at: 2026-09-28T23:06:51.216909Z reason: checked by hand person: user:you -$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +$ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py hello ``` That run is the one execution the person's act authorised: run it once more and it waits again, under a new approval. Running it *while* the wait is open returns the same approval rather than opening a second. `approvals reject` ends -a wait the other way, and the next run is denied with `approval_rejected`. +a wait the other way: every run of the same program is then denied with +`approval_rejected` until that wait's deadline — five minutes here, since the +rule you edited names no `review_deadline_seconds` — and a restart of the daemon +forgets it. ## 5. Prove it Put the rule back to `"allow"`, and ask for proof rather than a run: ```console -$ sayfirst instrument verify --pack subprocess --scope local -- python my_agent.py +$ sayfirst instrument verify --pack subprocess --scope local -- python3 my_agent.py hello governed subprocess subprocess.Popen process.spawn events=1 inspected: subprocess @@ -293,7 +332,7 @@ alone on stdout. ```console $ sayfirst-daemon down -SayFirst Control Plane stopped (pid 2174481) +SayFirst Control Plane stopped (pid 741184) policy and evidence are kept under ~/.sayfirst/quickstart ``` @@ -312,6 +351,11 @@ $ sayfirstd status socket: /run/user/1000/sayfirst/daemon.sock (the per-user default; no --socket was given) verified: false (server_uid None, expected None) unreachable: [Errno 2] No such file or directory +``` + +It exits `4`, like every read that could not ask. + +```console $ sayfirst ask --capability process.spawn socket: /run/user/1000/sayfirst/daemon.sock (the per-user default; no --socket was given) verified: false (server_uid not stated, expected not stated) @@ -319,7 +363,7 @@ could not ask: unreachable: [Errno 2] No such file or directory retryable: true $ echo $? 4 -$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +$ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py socket: /run/user/1000/sayfirst/daemon.sock (the per-user default; no --socket was given) nothing is there now, so an effect these packs name will fail closed; `sayfirst-daemon up --quickstart` starts a control plane at that address Traceback (most recent call last): @@ -340,6 +384,14 @@ escape. ## 7. Asking by hand, and reading back +Section 6 left the control plane stopped; start it again: + +```console +$ sayfirst-daemon up --quickstart +SayFirst Control Plane ready +… +``` + The same three outcomes without a program. `--scope` defaults to `local` for `ask`, and every other read names it: @@ -349,22 +401,22 @@ verified: true (server_uid 1000, expected 1000) outcome: allow reason: policy_allows capability: process.spawn in scope local -decision: 69717b64-c77f-4743-ab47-05b388c04d82 at 2026-09-23T18:58:34.376397Z +decision: 86c6debb-715a-4f28-9aa8-75026a395f48 at 2026-09-28T23:06:52.489393Z policy version: sha256:efc1746cea9b50cb31aa5c9f08de996cbefabc34a09d4250d8b68f67c709a5dc $ sayfirst ask --capability net.egress verified: true (server_uid 1000, expected 1000) outcome: suspend reason: policy_requires_review capability: net.egress in scope local -decision: 8d78b0a9-b383-4823-ade0-2c13776f112e at 2026-09-23T18:58:34.443382Z +decision: 30fac8bc-d1a1-4058-a7c4-1ff62e4db58d at 2026-09-28T23:06:52.574755Z policy version: sha256:efc1746cea9b50cb31aa5c9f08de996cbefabc34a09d4250d8b68f67c709a5dc -approval: 51396cbc-0c8c-40b8-ab82-5fd3c9b7fbde +approval: 335902ec-3ece-4b06-8856-9d45ef78e2e2 $ sayfirst ask --capability database.open verified: true (server_uid 1000, expected 1000) outcome: deny reason: policy_absent capability: database.open in scope local -decision: 0bee18d1-5649-4066-b6b2-5242e53c1eaa at 2026-09-23T18:58:34.516186Z +decision: e2791157-0385-4a79-86d0-abfb97cd83b4 at 2026-09-28T23:06:52.655469Z policy version: sha256:efc1746cea9b50cb31aa5c9f08de996cbefabc34a09d4250d8b68f67c709a5dc ``` @@ -376,18 +428,18 @@ rule saying no. One decision, explained in the daemon's own words and traced into the chain: ```console -$ sayfirst explain --scope local --decision 7fafc3e7-e079-4d6f-a170-6c3863ca647b -decision_ref: 7fafc3e7-e079-4d6f-a170-6c3863ca647b +$ sayfirst explain --scope local --decision 86c6debb-715a-4f28-9aa8-75026a395f48 +decision_ref: 86c6debb-715a-4f28-9aa8-75026a395f48 scope: local capability: process.spawn outcome: allow reason: policy_allows rule_id: local-processes-run policy_version: sha256:efc1746cea9b50cb31aa5c9f08de996cbefabc34a09d4250d8b68f67c709a5dc -decided_at: 2026-09-23T18:58:34.585650Z +decided_at: 2026-09-28T23:06:52.489393Z … -$ sayfirst trace --scope local --decision 7fafc3e7-e079-4d6f-a170-6c3863ca647b | tail -1 -chain: sequence 34, entry_hash 087c81168d9cd193456251c3928c93752cec6ddb4b247c6f0808778d9d627d98, grade observability +$ sayfirst trace --scope local --decision 86c6debb-715a-4f28-9aa8-75026a395f48 | tail -1 +chain: sequence 23, entry_hash ee313608e9c4f09b747c7ec9ac5d817e0945bbeb7b9f267f76f1d2c2c4e935d5, grade observability ``` Every read is itself recorded: `history`, `explain`, `trace` and `export` each @@ -406,15 +458,60 @@ chain: intact coverage: unknown issue: coverage_unknown verification: intact -saved: bundle.json (36 entries) +saved: bundle.json (29 entries) +``` + +### A closed epoch, verified + +`down` closes the daemon's evidence epoch and `up` opens the next, and only a range +inside a closed epoch can be checked whole. The history shows where the epoch closed — +the `recovery` entry just before the next `composition`: + +```console +$ sayfirst evidence history --scope local --from 1 --all | grep -E 'recovery|composition' +1 composition daemon b7e07cc5149332bda660b2bfd874409cc3c43c3fcc545bcdfe434eeca08d7992 +19 recovery daemon f36c43ff25d8a0c15b64dd55a089127fcab352ffa056420c536314ad63c76a3b +20 composition daemon 17c41de74accb1d7b03bf8864229f370c446623d9f528eae1f5387c22cb6463e +``` + +Export up to that entry with `--to`: + +```console +$ sayfirst evidence export --scope local --from 1 --to 19 --out closed.json +local_check: unverifiable +manifest: recomputes +chain: intact +coverage: complete +verification: intact +saved: closed.json (19 entries) +``` + +Coverage is `complete` — nothing in that range is missing — and the check still says +`unverifiable`, exit `7`, because section 4 put a person in that range: an approval, +and every decision that follows from one — the run it authorised, and any +`approval_rejected` denial — re-derives as `unverifiable` with the cause +`reason_outside_recipe`. A policy file cannot re-derive a human act, and the +verifier says so rather than counting it (`--json` shows each decision's +cause). A range with no person's act in it verifies — here the same epoch up +to the suspension's own entry, before anyone answered it (`sayfirst trace` on +the suspended decision names its sequence): + +```console +$ sayfirst evidence export --scope local --from 1 --to 10 --out quiet.json +local_check: confirmed +manifest: recomputes +chain: intact +coverage: complete +verification: intact +saved: quiet.json (10 entries) ``` -After a `down` and an `up`, the range that ends at the closed epoch's last entry -exports with coverage `complete`; `sayfirst evidence exports DIRECTORY` -re-verifies every saved bundle with the contract distribution alone, no daemon -needed. A decision a person granted re-derives as `unverifiable` with the cause -`reason_outside_recipe`, by design: an approval cannot be re-derived from a -policy file, and the verifier says so rather than counting it. +The epoch `up` opened in this section is still open, so a range inside it is +`coverage: unknown` until the next `down` closes it. + +`sayfirst evidence exports DIRECTORY` re-verifies every saved bundle with the contract +distribution alone, no daemon needed, and exits with the worst result among them. +An export never writes over a file: naming one that exists is refused (`64`). ## 8. A control plane of your own @@ -427,9 +524,12 @@ serving per_user at /run/user/1000/sayfirst/daemon.sock (acl: checked) ``` `daemon.toml` names the mode, the policy file and the evidence directory, all -absolute; `~/.sayfirst/quickstart/daemon.toml` is a working example. Given a -`[socket] path`, the daemon serves there instead, and every client command -takes the same path as `--socket`: +absolute; `~/.sayfirst/quickstart/daemon.toml` is a working example. Stop the +quickstart daemon first (`sayfirst-daemon down`), or give the copy an +`[evidence] path` and a `[socket] path` of its own: two daemons never share an +evidence directory (`decision_store_unusable`) nor an address (`socket_in_use`), +and the second refuses to start. Given a `[socket] path`, the daemon serves +there instead, and every client command takes the same path as `--socket`: ```console $ sayfirst ask --capability process.spawn --socket /srv/sayfirst/daemon.sock diff --git a/README.md b/README.md index b583848..39c5cbc 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ anything that matters: *may I do this, with these arguments, as this account?* the boundary (`sayfirst-boundary`) holds the grant for exactly one execution. - **A person is in the loop by construction, not by dashboard.** A suspension is a wait. `sayfirst approvals approve` ends it once, the deadline comes from - the policy, and a rejection is final. + the policy, and a rejection stands until that deadline, or until the daemon + restarts. - **Every decision leaves a record**, and `sayfirst evidence export` saves a bundle a third party verifies offline, with the contract alone. - **The evidence is honest about itself.** An export of an epoch the daemon has @@ -39,7 +40,7 @@ flowchart LR ```console $ uv tool install sayfirst-cli --with-executables-from sayfirst-control-plane --with-executables-from sayfirstd $ sayfirst-daemon up --quickstart -$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +$ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py ``` The first installs the client (`sayfirst`), the daemon (`sayfirst-daemon`) and @@ -48,7 +49,7 @@ install` takes one package, so the other two ride on `--with-executables-from`. The second writes a readable starter policy and a per-user configuration under `~/.sayfirst/quickstart/` **if they are not there**, starts the real daemon in the background, and says « ready » only once that daemon has answered. The third -runs a program you already have, under the `python` you named, with every +runs a program you already have, under the `python3` you named, with every process it starts asked about first: ```console @@ -60,7 +61,7 @@ policy: ~/.sayfirst/quickstart/policy.toml evidence: ~/.sayfirst/quickstart/evidence integrity grade: observability (the caller can write the store; the chain detects accidental corruption only) … -$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +$ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py hello ``` @@ -82,7 +83,7 @@ restarted — and running `up --quickstart` again never writes over it. ```console $ sayfirstd status # what the daemon says about itself -$ sayfirst instrument verify --pack subprocess --scope local -- python my_agent.py +$ sayfirst instrument verify --pack subprocess --scope local -- python3 my_agent.py $ sayfirst-daemon down # stops the daemon `up` started, and only that one ``` @@ -92,7 +93,7 @@ command on that page was run, in that order, before it was written down. ### What the index holds -Everything the three commands above need is on the Python index at 0.3.0: +Everything the three commands above need is on the Python index at 0.3.1: `sayfirst-cli`, the `sayfirst-contract` it speaks and the `sayfirst-boundary` a governed program holds its grants in, and the control plane's `sayfirst-control-plane` and `sayfirstd`. The first command installs all five @@ -119,12 +120,13 @@ verified, and one answer rendered as it was given: allow, deny or suspend, and **It puts the boundary in front of somebody else's program.** `sayfirst instrument run --pack PACK … -- ` runs a program with the named effects asked about first, reversibly and writing nothing anywhere; a program spelled -`python app.py` is handed, whole command and all, to the interpreter that was -named, so it keeps its own environment's dependencies; `instrument verify` -runs it again under the interpreter's own audit hook and proves, from that and -the scope's evidence chain alone, that every effect of a named kind was preceded -by a decision; `instrument apply` is reserved for the committed code -modification and refuses, saying so. `sayfirst packs list` prints the packs this +`python3 app.py` (or `python app.py` inside an activated environment) is +handed, whole command and all, to the interpreter that was named, so it keeps +its own environment's dependencies; `instrument verify` runs it again under the +interpreter's own audit hook and proves, from that and the scope's evidence +chain alone, that every effect of a named kind was preceded by a decision; +`instrument apply` is reserved for the committed code modification and refuses, +saying so. `sayfirst packs list` prints the packs this distribution ships — `database`, `http-client`, `subprocess` — each of which `--pack` takes by that name, while a pack of your own is a directory spelled with a separator, `--pack ./own-pack` ([`docs/PACKS.md`](docs/PACKS.md)). A program whose effects are @@ -224,7 +226,7 @@ contract is built from a checkout of the control plane's repository, at the tag this client pins: ```console -$ SAYFIRST_CONTRACT_SOURCE=../sayfirst-control-plane SAYFIRST_CONTRACT_REF=v0.3.0 ./scripts/gate.sh +$ SAYFIRST_CONTRACT_SOURCE=../sayfirst-control-plane SAYFIRST_CONTRACT_REF=v0.3.1 ./scripts/gate.sh ``` The workflow does the same and carries no credential of any kind: a fork can @@ -251,9 +253,9 @@ GitHub's private reporting, never a public issue — [`LICENSE`](LICENSE), ## Status -**0.2.0 is the first public release**, 2026-09-17. What is *not* here is named -too, because a surface a reader assumes is an overclaim: `connect`, `profile`, -`whoami`, `integrate` and `version` are in +**0.2.0 is the first public release**, 2026-09-17. 0.3.1 is the current one. +What is *not* here is named too, because a surface a reader assumes is an +overclaim: `connect`, `profile`, `whoami`, `integrate` and `version` are in [`docs/PARTITION.md`](docs/PARTITION.md) and none of them exists here. Next, and only what a document in this tree already says: the `evidence` grade of diff --git a/docs/PACKS.md b/docs/PACKS.md index dc3c524..b0003c4 100644 --- a/docs/PACKS.md +++ b/docs/PACKS.md @@ -156,15 +156,19 @@ and why it is measured rather than guarded. A run can also report **no verdict at all**: the chain could not be read — before the program started, or while it ran — and it exits `4`; or the -verifier never saw the program's own code start (a target the interpreter runs -from bytecode with no source beside it), and it exits `7`, because the plane -was asked and what could not be done was the watching. None of these is a -verdict about the program, and none is rendered as one; each carries its own -problem code and its own sentence, because « this run established an -absence », « this run never began watching » and « the chain could not be -read » are different facts. The harness says which of its own endings happened -in a file of its own, so the answer is never chosen by the number the verified -program happened to exit with. +verifier never saw the program's own code start, and it exits `7`, because the +plane was asked and what could not be done was the watching. The second has +two causes and the sentence names which: a target the interpreter runs from +bytecode with no source beside it, which ran unseen; or a program that ended on +an exception before a line of it was seen — a script that does not compile, a +module whose bytecode another Python wrote — named with the exception it ended +on. None of these is a verdict about the program, and none is rendered as one; +each carries its own problem code and its own sentence, because « this run +established an absence », « this run never began watching » and « the chain +could not be read » are different facts. The harness says which of its own +endings happened in a file of its own, whether the program returned or raised, +so the answer is never chosen by the number the verified program happened to +exit with. A chain read the control plane **refused** — a scope it will not read, a principal it does not admit — exits `3` instead, as it does for every other command of this client: the plane was asked, and it said no. The harness writes diff --git a/pyproject.toml b/pyproject.toml index 20a7040..c741b1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 [project] name = "sayfirst-cli" -version = "0.3.0" +version = "0.3.1" description = "The product command-line interface of the sayfirst control plane" readme = "README.md" requires-python = ">=3.12,<3.15" @@ -20,7 +20,7 @@ classifiers = [ # promise made here — it is measured after an install. `sayfirst-boundary` joins # it because a governed program holds a grant in it (article 10); it depends on # the contract only, and it is not the server (article 14). -dependencies = ["sayfirst-contract==0.3.0", "sayfirst-boundary==0.3.0"] +dependencies = ["sayfirst-contract==0.3.1", "sayfirst-boundary==0.3.1"] # The public repository this distribution sends a reader to, created fresh in # the act that published it (2026-09-17) under the names the operator chose for @@ -41,7 +41,7 @@ sayfirst = "sayfirst_cli.main:run" dev = [ "pytest==8.4.1", "ruff==0.12.12", - "sayfirst-contract[stub]==0.3.0", + "sayfirst-contract[stub]==0.3.1", ] [build-system] diff --git a/src/sayfirst_cli/instrument/harness.py b/src/sayfirst_cli/instrument/harness.py index 16a1b16..110011d 100644 --- a/src/sayfirst_cli/instrument/harness.py +++ b/src/sayfirst_cli/instrument/harness.py @@ -216,12 +216,21 @@ #: What a run says when it never saw the program's own code begin. It is not a #: verdict and it is not `not-exercised`: nothing was watched, so nothing about -#: the program was established — not even an absence. +#: the program was established — not even an absence. WHY it never began is said +#: beside it and not in it, because there are two answers and they are opposite +#: facts about the program: it ran without being seen (`RAN_UNSEEN`), or it +#: ended on an exception before this run saw a line of it — a script that does +#: not compile, bytecode another Python wrote — and may never have run at all. NEVER_STARTED: Final[str] = ( "the verifier never saw the program's own code start, so nothing about this program " - "was watched and no verdict is reported: the interpreter ran it from a code object " - "that carries no file this run resolved, which is what a module shipped as bytecode " - "with no source beside it looks like" + "was watched and no verdict is reported" +) + +#: Why, for a program that ended without raising: the interpreter ran code this +#: run could not recognise as the program's own. +RAN_UNSEEN: Final[str] = ( + "the interpreter ran it from a code object that carries no file this run resolved, " + "which is what a module shipped as bytecode with no source beside it looks like" ) #: The configuration member naming the file this harness says its own ending in. @@ -413,8 +422,8 @@ class Watch: that matters. A target whose executed code object does not carry a file the lookup resolved — a module shipped as bytecode with no source beside it — leaves this gate shut, and a run whose gate never opened concludes nothing - at all: it says so and answers « could not read », rather than reporting - the absence it never established (`_prove` says where). + at all: it says so and answers « could not check », rather than reporting + the absence it never established (`_say_how_the_run_ended` says where). **Not inside a consultation.** A consultation reads the chain, and those reads are themselves acts the interpreter reports. Per thread, so an event @@ -923,6 +932,7 @@ def say(outcome: str, detail: str, *, problem: Problem | None = None) -> None: sys.addaudithook(_consulting(chain, by_event, uninterposed, watch, inner)) ending: int | None = None refused_the_invocation = False + raised: BaseException | None = None try: ending = _run_the_target(configured, packs, target, watch, correlation) except HarnessMisuse as misuse: @@ -950,6 +960,17 @@ def say(outcome: str, detail: str, *, problem: Problem | None = None) -> None: # the `finally`, from the watch rather than from here, so that a target # which caught the exception cannot bury it. pass + except BaseException as failure: # held, said, and raised again below + # The program's own ending, or the interpreter's refusal to begin it — a + # script that does not compile, bytecode another Python wrote. It used + # to leave this function before anything below said how the run ended, + # and the command read the missing outcome as « the harness never + # reached its own first statement »: exit 4, « could not ask », about a + # plane that had been asked and had answered. So it is held here, the + # run's ending is said below exactly as for a program that returned, and + # it is raised again last: the traceback the interpreter renders is the + # program's diagnostic, and saying an ending is no reason to swallow it. + raised = failure finally: # The program is not done because its main module returned, so the # watch stays armed until it is — its own threads run out and its own @@ -966,6 +987,26 @@ def say(outcome: str, detail: str, *, problem: Problem | None = None) -> None: ): with watch.ours(): _write_report(configured, packs, watched, head, ending, connection) + answered = _say_how_the_run_ended(say, watch, target, raised) + if raised is not None: + raise raised + return answered + + +def _say_how_the_run_ended( + say: Callable[..., None], + watch: Watch, + target: Sequence[str], + raised: BaseException | None, +) -> int: + """Which of this harness's own endings happened, said whichever way the program ended. + + `raised` is what the program ended on, or `None` for a program that + returned. It changes one thing: WHY a gate that never opened stayed shut. + Everything else is the same ending for a program that raised as for one + that returned — which is the point, since the command reads the ending and + never the program's number. + """ if watch.unreadable is not None: say( CHAIN_UNREADABLE_DURING, @@ -983,8 +1024,13 @@ def say(outcome: str, detail: str, *, problem: Problem | None = None) -> None: # `not-exercised`, byte for byte — so a run that watched nothing at all # read exactly like a run that established an absence, which is the # distinction the rest of this file is fastidious about (article 2). - say(GATE_NEVER_OPENED, " ".join(target)) - sys.stderr.write(f"{NEVER_STARTED}\n") + named = " ".join(target) + if raised is None: + why = f"{RAN_UNSEEN}: {named}" + else: + why = f"{named} ended on {_ended_on(raised)}" + say(GATE_NEVER_OPENED, why) + sys.stderr.write(f"{NEVER_STARTED}: {why}\n") return exit_codes.EXIT_COULD_NOT_CHECK # No detail: the only path that reads this one is a report that will not # parse, and the report lives in a directory the command removes before the @@ -993,6 +1039,21 @@ def say(outcome: str, detail: str, *, problem: Problem | None = None) -> None: return exit_codes.EXIT_ALLOW +def _ended_on(raised: BaseException) -> str: + """The exception a program ended on: its class's name, and its message when it has one. + + The message is the program's own text, which is the program's to get wrong: a `__str__` + that raises must not cost the run the ending it is about to say, so it + costs only the message. + """ + name = type(raised).__name__ + try: + message = str(raised) + except Exception: # any failure of the program's own text + return name + return f"{name}: {message}" if message else name + + def _wait_for_the_program(chain: Chain) -> None: """Run out everything the program left to do, before anything is concluded. diff --git a/src/sayfirst_cli/instrument/interpreter.py b/src/sayfirst_cli/instrument/interpreter.py index 6ab22a9..a151629 100644 --- a/src/sayfirst_cli/instrument/interpreter.py +++ b/src/sayfirst_cli/instrument/interpreter.py @@ -298,12 +298,23 @@ def selection(target: Sequence[str]) -> tuple[str, list[str]] | None: def locate(word: str) -> str: - """The interpreter a word names, as an absolute path, or the misuse that it names none.""" + """The interpreter a word names, as an absolute path, or the misuse that it names none. + + Nothing is substituted: a word the shell would not run is not run. But a stock + Debian or Ubuntu has `python3` and no `python`, so when the one named is absent and + `python3` is on the same PATH the refusal says so. + """ found = shutil.which(word) if found is None: + hint = "" + if word != "python3" and shutil.which("python3") is not None: + hint = ( + f" — this PATH has python3: name it (-- python3 …), or activate the " + f"environment that provides {word!r}" + ) raise launch.LaunchMisuse( f"this launcher found no interpreter at {word!r}: a target whose first word is " - f"spelled like one is run by that interpreter, and there is none to run it" + f"spelled like one is run by that interpreter, and there is none to run it{hint}" ) return os.path.abspath(found) diff --git a/tests/test_instrument_verify.py b/tests/test_instrument_verify.py index 91b3d20..276825e 100644 --- a/tests/test_instrument_verify.py +++ b/tests/test_instrument_verify.py @@ -704,8 +704,8 @@ def test_a_gate_that_never_opens_judges_nothing_at_all() -> None: """A code object no resolved file matches leaves the gate shut for ever. Nothing is then judged, and the run must not conclude an absence it never - established: `_prove` answers « the verifier never saw the program's own - code start » and exit 4 rather than `not-exercised` + established: the harness answers « the verifier never saw the program's own + code start » and exit 7 rather than `not-exercised` (`test_a_gate_that_never_opened_says_so_and_reports_no_verdict` holds that end to end). This is the half of it that belongs to the watch. """ @@ -975,6 +975,62 @@ def test_a_chain_that_answered_and_then_stopped_is_a_finding_with_the_failure_be assert "this connection was opened on contract version" in stderr +@pytest.mark.parametrize("as_json", [False, True]) +def test_a_chain_that_never_answered_is_said_when_the_program_then_raises_its_own( + tmp_path: Path, as_json: bool +) -> None: + """The chain's ending survives a program that answers the abort with an exception of its own. + + The abort is raised INTO the program, which may catch it — the case the + watch carries the problem for — and may then end on an exception of its + own. That exception used to leave the harness before it said how the run + ended, so the command read no outcome at all and answered the generic « did + not run to a conclusion » under a code it minted, `answer_unreadable`, in + place of the transport's `generation_unsupported` and its sentence. The + same 4, and a different, wrong account of why. + + The program's own exception still reaches the error stream: it is the + program's diagnostic, and the harness saying its ending is no reason to + swallow it. Raised `from None`, so the transport's sentence can only reach + the stream by the harness saying it. + """ + pack = plant_spawn_pack(tmp_path) + tree = a_quiet_tree( + tmp_path, + body=( + "import subprocess\n\n" + "try:\n" + " subprocess.run(['true'], check=True)\n" + "except RuntimeError:\n" + " raise LookupError('the program ended in its own way') from None\n" + ), + ) + routes = {"/scopes/": (200, [no_evidence_page(), foreign_generation_page()])} + with answering_by_path(tmp_path / "d.sock", routes) as address: + code, stdout, stderr = verify( + "--pack", + str(pack), + "--socket", + str(address), + "--scope", + "local", + "--ungoverned", + *(["--json"] if as_json else []), + "--", + "app.py", + cwd=tree, + ) + assert code == exit_codes.EXIT_COULD_NOT_ASK, (stdout, stderr) + assert "did not run to a conclusion" not in stdout + stderr + assert "the chain could not be read while the program ran" in stderr + assert "this connection was opened on contract version" in stderr + assert "LookupError: the program ended in its own way" in stderr + if as_json: + assert json.loads(stdout)["problem"]["code"] == "generation_unsupported" + else: + assert stdout == "" + + # --- `--json` answers in the envelope on every path ----------------------------- @@ -1662,6 +1718,91 @@ def test_a_gate_that_never_opened_says_so_and_reports_no_verdict( assert stdout == "" +def a_program_that_never_starts(root: Path, shape: str, marker: Path) -> tuple[Path, list[str]]: + """A target the interpreter gives up on before a line of it runs, and how to name it. + + `syntax` is a script that does not compile. `foreign-bytecode` is a `-m` + module whose only form is bytecode this interpreter will not load — the + shape a `.pyc` written by another Python version takes, read here as a magic + number no interpreter uses. Either would leave `marker` behind if a line of + it ran, which is how the test knows that none did. + """ + tree = root / shape + tree.mkdir() + spawn = f"import subprocess\n\nsubprocess.run(['touch', {str(marker)!r}], check=True)\n" + if shape == "syntax": + (tree / "app.py").write_text(spawn + "subprocess.run(['true']\n", encoding="utf-8") + return tree, ["app.py"] + source = tree / "solo.py" + source.write_text(spawn, encoding="utf-8") + compiled = tree / "solo.pyc" + py_compile.compile(str(source), cfile=str(compiled), doraise=True) + source.unlink() + with compiled.open("r+b") as bytecode: + bytecode.write(b"\xff\xff\r\n") + return tree, ["-m", "solo"] + + +@pytest.mark.parametrize("as_json", [False, True]) +@pytest.mark.parametrize( + ("shape", "ended_on"), [("syntax", "SyntaxError"), ("foreign-bytecode", "ImportError")] +) +def test_a_program_that_never_started_says_so_and_reports_no_verdict( + tmp_path: Path, shape: str, ended_on: str, as_json: bool +) -> None: + """Exit 7 and the sentence, for a program the interpreter never began. + + Measured as the defect, through the shipped command against a real daemon + that had answered: a script with a syntax error, and a module whose bytecode + another Python wrote, each ended `verify` with exit 4 and « the verification + did not run to a conclusion » — which a shell reads as « the control plane + could not be asked », about a plane that had been asked. The harness said + its own ending only when the program RETURNED; one that raised on its way + in left no outcome file, and an absent file is the reading reserved for a + harness that never reached its own first statement. + + The gate never opened, so this is the ending `docs/PACKS.md` gives a run + that never saw the program's own code start: 7, because the plane was asked + and what could not be done was the watching. The sentence says what the + program ended on and does NOT say the interpreter ran it: nothing ran, which + is what the marker proves, and the bytecode explanation beside the other + shape of this ending would be a fact this run never observed. + """ + pack = plant_spawn_pack(tmp_path) + marker = tmp_path / "ran" + tree, target = a_program_that_never_starts(tmp_path, shape, marker) + with answering_by_path(tmp_path / "d.sock", {"/scopes/": (200, no_evidence_page())}) as address: + code, stdout, stderr = verify( + "--pack", + str(pack), + "--socket", + str(address), + "--scope", + "local", + "--ungoverned", + *(["--json"] if as_json else []), + "--", + *target, + cwd=tree, + ) + assert code == exit_codes.EXIT_COULD_NOT_CHECK, (stdout, stderr) + assert code != exit_codes.EXIT_COULD_NOT_ASK + assert not marker.exists(), "a line of the program ran, so this is not the shape under test" + assert "not-exercised" not in stdout + stderr + assert "did not run to a conclusion" not in stdout + stderr + assert "the interpreter ran it" not in stdout + stderr + # The harness says it on its own stream whichever rendering was asked for. + assert "never saw the program's own code start" in stderr + assert f"ended on {ended_on}" in stderr + if as_json: + envelope = json.loads(stdout) + assert envelope["problem"]["code"] == "answer_unreadable" + assert "never saw the program's own code start" in envelope["problem"]["message"] + assert f"ended on {ended_on}" in envelope["problem"]["message"] + else: + assert stdout == "" + + def test_the_watch_remembers_that_it_once_started(tmp_path: Path) -> None: """`disarm` clears `started`, and the run still has to know it ever did. diff --git a/tests/test_interpreter_target.py b/tests/test_interpreter_target.py index f33e354..875caa0 100644 --- a/tests/test_interpreter_target.py +++ b/tests/test_interpreter_target.py @@ -594,3 +594,59 @@ def test_a_safe_path_is_kept_under_a_named_interpreter_too( ) # fmt: skip assert finished.returncode == 0, finished.stderr assert finished.stdout == plain.stdout + + +def test_a_missing_python_names_the_python3_on_the_same_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Stock Debian and Ubuntu have python3 and no python: say which is there.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + (bin_dir / "python3").symlink_to(sys.executable) + monkeypatch.setenv("PATH", str(bin_dir)) + with pytest.raises(interpreter.launch.LaunchMisuse) as refusal: + interpreter.locate("python") + message = str(refusal.value) + assert "found no interpreter at 'python'" in message + assert "this PATH has python3" in message + + +def test_a_missing_python_with_no_python3_names_nothing_else( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PATH", str(tmp_path)) + with pytest.raises(interpreter.launch.LaunchMisuse) as refusal: + interpreter.locate("python") + assert "this PATH has" not in str(refusal.value) + + +def test_a_missing_python3_is_not_pointed_at_itself( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + (bin_dir / "python3.12").symlink_to(sys.executable) + monkeypatch.setenv("PATH", str(bin_dir)) + with pytest.raises(interpreter.launch.LaunchMisuse) as refusal: + interpreter.locate("python3") + assert "this PATH has" not in str(refusal.value) + + +def test_the_command_names_the_python3_when_python_is_missing(tmp_path: Path) -> None: + """The same refusal, from the command a person types, on a PATH like stock Ubuntu's.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + (bin_dir / "python3").symlink_to(sys.executable) + marker = tmp_path / "ran" + (tmp_path / "app.py").write_text(f"open({str(marker)!r}, 'w').close()\n", encoding="utf-8") + pack = plant_spawn_pack(tmp_path) + environment = {**os.environ, "PATH": str(bin_dir)} + finished = instrument( + "run", "--pack", str(pack), "--socket", str(tmp_path / "absent.sock"), "--scope", + "local", "--", "python", "app.py", cwd=tmp_path, env=environment, + ) # fmt: skip + assert finished.returncode == exit_codes.EXIT_MISUSE, finished.stderr + assert "found no interpreter at 'python'" in finished.stderr + assert "this PATH has python3" in finished.stderr + assert "Traceback" not in finished.stderr + assert not marker.exists() diff --git a/tests/test_quickstart_names_what_exists.py b/tests/test_quickstart_names_what_exists.py index 58c09d6..3f2d710 100644 --- a/tests/test_quickstart_names_what_exists.py +++ b/tests/test_quickstart_names_what_exists.py @@ -111,3 +111,27 @@ def test_the_rule_fires_on_a_verb_the_command_lacks() -> None: def test_the_rule_fires_on_a_flag_the_verb_lacks() -> None: text = help_text(["ask"]) assert "--url" not in text, "the planted flag exists now; choose another" + + +PAGES = (REPOSITORY / "README.md", QUICKSTART, REPOSITORY / "docs" / "PACKS.md") +_BARE_PYTHON = re.compile(r"(?:\s--\s|^\$\s)python(?:\s|$)") + + +def test_no_command_on_a_page_starts_a_bare_python() -> None: + """Stock Debian and Ubuntu have no `python`: a page that types one sends a reader to 64.""" + offenders = [ + f"{page.name}: {line}" + for page in PAGES + for block in console_blocks(page.read_text(encoding="utf-8")) + for line in block.splitlines() + if _BARE_PYTHON.search(line) + ] + assert offenders == [], offenders + + +def test_the_quickstart_starts_the_daemon_again_before_asking_by_hand() -> None: + """Section 6 ends with `down`; section 7's first ask needs a daemon.""" + text = QUICKSTART.read_text(encoding="utf-8") + seven = text.split("\n## 7.", 1)[1].split("\n## 8.", 1)[0] + first_command = next(line for line in seven.splitlines() if line.startswith("$ ")) + assert first_command == "$ sayfirst-daemon up --quickstart", first_command