From 7000ec2676f1ee3b4dc7670240141d40841c7f93 Mon Sep 17 00:00:00 2001 From: fredaime Date: Wed, 23 Sep 2026 20:33:10 +0200 Subject: [PATCH] release: 0.3.0 The command-line client at 0.3.0, published as one commit: the tree reviewed in the private repository it is built in. CHANGELOG.md says what changed for a caller; in short: - `--socket` is optional for a per-user profile on every verb, and `--pack` takes the name of a pack this distribution ships as well as a directory. - A target led by an interpreter, or an executable whose shebang names one, runs under that interpreter with the boundary in it; a runner in that place is refused. - `instrument run --follow-children` installs the boundary in the Python children a program starts with its environment. - `instrument verify` matches a run's records by a correlation it stamps, asks for every effect, counts one spawn once and a process `multiprocessing` starts, and answers 3 for a refused chain read. - `instrument run` ends with the outcome's own status when the program lets a boundary outcome escape. - Every connection is bounded, and the suite runs on Python 3.12, 3.13 and 3.14. - QUICKSTART.md is a transcript of 0.3.0. It needs `sayfirst-contract` and `sayfirst-boundary` 0.3.0, published by the control plane's repository at its `v0.3.0` tag. Signed-off-by: fredaime --- .github/workflows/ci.yml | 19 +- CHANGELOG.md | 114 +++ QUICKSTART.md | 565 +++++++----- README.md | 141 +-- docs/PACKS.md | 265 +++++- docs/PARTITION.md | 17 +- pyproject.toml | 14 +- scripts/gate.sh | 20 +- src/sayfirst_cli/approvals.py | 25 +- src/sayfirst_cli/ask.py | 27 +- src/sayfirst_cli/evidence.py | 94 +- src/sayfirst_cli/exit_codes.py | 8 +- .../instrument/_bootstrap/sitecustomize.py | 106 +++ src/sayfirst_cli/instrument/commands.py | 165 +++- src/sayfirst_cli/instrument/designation.py | 111 +++ src/sayfirst_cli/instrument/engine.py | 40 +- src/sayfirst_cli/instrument/follow.py | 156 ++++ src/sayfirst_cli/instrument/harness.py | 838 ++++++++++++++++-- src/sayfirst_cli/instrument/interpreter.py | 480 ++++++++++ src/sayfirst_cli/instrument/launch.py | 183 +++- src/sayfirst_cli/instrument/verify.py | 139 ++- src/sayfirst_cli/packs/__init__.py | 13 +- .../packs/http-client/interpose.py | 165 +++- .../packs/subprocess/interpose.py | 17 +- src/sayfirst_cli/packs/subprocess/pack.toml | 26 + src/sayfirst_cli/packs_cmd.py | 68 +- src/sayfirst_cli/pages.py | 8 +- src/sayfirst_cli/reads.py | 70 +- src/sayfirst_cli/render.py | 6 +- src/sayfirst_cli/trace.py | 16 +- tests/canned_daemon.py | 16 +- tests/documents.py | 89 +- tests/test_approvals.py | 34 +- tests/test_contract_words.py | 40 + tests/test_decided_name.py | 25 +- tests/test_default_socket.py | 211 +++++ tests/test_engine.py | 57 ++ tests/test_evidence_export_and_exports.py | 39 +- tests/test_evidence_history_and_audit.py | 105 ++- tests/test_evidence_reads.py | 5 +- tests/test_exit_codes.py | 2 +- tests/test_follow_children.py | 404 +++++++++ tests/test_http_client_pack.py | 273 +++++- tests/test_instrument_run.py | 303 ++++++- tests/test_instrument_verify.py | 306 ++++++- tests/test_interpreter_target.py | 596 +++++++++++++ tests/test_pack_designation.py | 144 +++ tests/test_packs_cmd.py | 29 +- tests/test_packs_shipped.py | 1 + tests/test_reads.py | 7 +- tests/test_review_bounds.py | 111 +++ ...verification_claims_what_it_established.py | 541 +++++++++++ tests/test_verify_asks_every_effect.py | 82 ++ tests/test_verify_pairs_an_inner_spawn.py | 241 +++++ 54 files changed, 6938 insertions(+), 639 deletions(-) create mode 100644 src/sayfirst_cli/instrument/_bootstrap/sitecustomize.py create mode 100644 src/sayfirst_cli/instrument/designation.py create mode 100644 src/sayfirst_cli/instrument/follow.py create mode 100644 src/sayfirst_cli/instrument/interpreter.py create mode 100644 tests/test_contract_words.py create mode 100644 tests/test_default_socket.py create mode 100644 tests/test_follow_children.py create mode 100644 tests/test_interpreter_target.py create mode 100644 tests/test_pack_designation.py create mode 100644 tests/test_review_bounds.py create mode 100644 tests/test_verification_claims_what_it_established.py create mode 100644 tests/test_verify_asks_every_effect.py create mode 100644 tests/test_verify_pairs_an_inner_spawn.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6534aee..41a8364 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,9 +41,11 @@ # state", so `scripts/gate.sh` has three outcomes and this workflow renders # three and not two: # -# gate (full) the contract was built from the control +# gate (full, python X.Y) the contract was built from the control # plane's public repository, at the tag this -# client pins, and every check ran. +# client pins, and every check ran — once per +# interpreter `requires-python` admits, since +# a release installs on any of them. # `scripts/gate.sh` exits 0. # gate (reduced — contract no checkout of the control plane was made, # absent, some checks not run) so the contract is absent. Format, lint and @@ -191,10 +193,18 @@ jobs: fi full: - name: gate (full) + name: gate (full, python ${{ matrix.python }}) needs: reachability if: needs.reachability.outputs.contract == 'reachable' runs-on: ubuntu-latest + # Every interpreter `requires-python` admits. Python 3.14 changed what + # `Path.exists` answers and gave `json` a `__main__`, and a gate that ran one + # interpreter found neither: a range this client installs on is a range + # this gate runs. + strategy: + fail-fast: false + matrix: + python: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v5 with: @@ -219,12 +229,13 @@ jobs: env: SAYFIRST_CONTRACT_SOURCE: ${{ github.workspace }}/sayfirst-control-plane SAYFIRST_CONTRACT_REF: ${{ needs.reachability.outputs.tag }} + SAYFIRST_PYTHON: ${{ matrix.python }} run: | status=0 ./scripts/gate.sh || status=$? case "$status" in 0) - echo "### gate: full green" >> "$GITHUB_STEP_SUMMARY" + echo "### gate: full green (python ${{ matrix.python }})" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "Format, lint, every test and the dependency-closure guard of articles 13" >> "$GITHUB_STEP_SUMMARY" echo "and 14 ran against a contract built from the control plane's public" >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8542c..1d4bdc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,120 @@ declares; a release with no section fails the gate. ## Unreleased +## 0.3.0 + +- **`--socket` is optional for a per-user profile**, on every verb that opens a connection. Given + none, the client looks at the per-user default address — the contract's own rule, the one a + per-user daemon given no address binds by — and at nothing else: nothing is searched for, and + whoever answers is still verified to be the expected account's process before a byte is sent. A + `--socket` somebody typed is the address. A system profile is never given one and still names + it (`64` otherwise). **What changes for a caller:** an invocation with no `--socket` used to end + as a usage error (`2`); it now asks, and with nobody at the default address it answers « could + not ask » (`4`), naming the address it looked at. `evidence audit` with neither `--file` nor + `--socket` is an online audit at that address rather than a usage error. Needs the contract + distribution that publishes the rule, which is the one after 0.2.0. +- **`--pack` takes the name of a pack this distribution ships** (`--pack subprocess`), as well as + a directory. The spelling alone decides: a designation with a path separator in it, or `.` or + `..`, is a directory; a bare word is a shipped pack's name and is never read as a directory of + the working directory. **What changes for a caller:** a pack directory designated by a bare + relative word (`--pack own-pack`) is now refused (`64`) with the spelling that names it + (`--pack ./own-pack`). Absolute paths and paths with a separator are read exactly as before. + `packs check` takes the same two spellings. `docs/PACKS.md` says why this is a designation and + not a registry. +- **A target whose first word is `python`, `python3` or `python3.N` is run by that interpreter.** + The whole command is handed to it, the boundary is installed in that process, and the client, + the boundary and the contract are lent to it by name — three packages and their metadata, and + nothing else of this environment. A program in a project's own environment therefore keeps its + dependencies when this client is installed as a tool. The interpreter has to be Python 3.12 or + later, and interpreter options are not carried. Before this, such a target was refused (`64`) as + a script that does not exist; a target with no interpreter word runs as it always did. +- **An executable script hands over by its shebang.** An agent started as a console script + (`-- ./myagent`) rather than as `python …` is run by the interpreter its shebang names — + including `#!/usr/bin/env python3` — so a console-script agent in a project's environment keeps + its dependencies too. A shebang naming this same interpreter, and a non-executable file, are + unchanged. **What changes for a caller:** an executable whose shebang names another Python used + to run in this command's interpreter and fail to import the project's packages; it now runs in + the interpreter the shebang names. +- **A runner in the first position is refused** (`64`): `-- uv run app.py`, `-- poetry run app.py` + and their kind start an interpreter the boundary is not in, so handing the runner over would + govern nothing. The refusal names the two spellings that work — name the interpreter, or ask the + runner for it once. Before, `uv` was reported as a script that does not exist. +- **`instrument verify` matches a run's records by a correlation it stamps, not by their + connection.** A program that makes two effects of different kinds is two asks, and the shipped + boundary holds one connection per grant, so its records span two connections. They are still one + run's, told so by a per-run token the verifier stamps on every ask and the plane records on the + effect (`correlation_source: boundary_supplied`). **The defect this fixes:** such a run reported + the second effect `unjudged` and exited `7`; it now verifies clean. The token also tells a + concurrent same-account run's records apart, which the connection check could not, and it holds + the first record as well as the rest. Needs the boundary distribution that stamps it, the one + after 0.2.0. +- **The `unjudged:` line names the reason the run actually recorded**, one per distinct cause, + rather than always saying an effect named a start file. A count made of a damaged range, an + interrupted walk, another execution's record or an uninterposed path now reads as what it was. +- `docs/PACKS.md` records, with the evidence, why no convenience pack for `requests`, `httpx`, + `os.system` or a Postgres driver is shipped — a limit of the verifier, not of the classification. + The verifier confirms an effect from a distinct CPython audit event and shares no state with the + engine; `requests`/`httpx`/Postgres emit nothing distinct from `socket.connect`, and `os.system`'s + module is `os`, which the engine imports throughout, so a pack for it would make the engine- + agnosticism guard read every `import os` as a special case. Each could be governed by hand against + `sayfirst-boundary`; none can be shipped as a verifiable pack without weakening a guard. +- **`instrument run --follow-children`** installs the boundary in the Python children the program + spawns with its environment, before the child's code runs, so a program that starts workers or + tools of its own has their effects governed too — and a grandchild started the same way. It works + by a `sitecustomize` on the child's import path (`src/sayfirst_cli/instrument/follow.py`), needs + this client installed in the interpreter the children run, and fails a child **closed** once the + bootstrap runs: a child that cannot install the boundary raises before its program runs, and a + child whose daemon is unreachable fails at the ask exactly as the parent does. A child the + bootstrap never reaches is not followed and runs as it would without the flag: one started with + `-S`, `-I` or `-E`, or given a `PYTHONPATH` of its own. It is `run`'s flag, not `verify`'s: a spawned + child is a separate process the single-process proof cannot see (`harness.py` counts such a child + as coverage it could not judge), so following would govern what the proof misses. +- With no `--socket` and nothing at the default address, `instrument run` says which address it + looked at before the program starts. Nothing else about that run changes: a program that asks + nothing still runs, and an effect a pack names still fails closed, as the program's own + exception. +- The quickstart is three commands, and the page is a transcript of them. +- **`instrument run` ends with the outcome's own status when the program does not handle it.** A + boundary outcome the program lets escape — denied, suspended, refused, could not ask — used to + end the run as any uncaught exception does, with the interpreter's `1`, which is this client's + « deny » for all four. The traceback is still the program's; the status is now `1`, `5`, `3` or + `4`. A program that catches the outcome and exits on its own still ends with its own status. +- **`instrument verify` asks for every effect.** The boundary in front of a verified program holds + no grant, so an identical effect repeated while a grant lived — answered with nothing asked and + nothing recorded — is no longer reported `ungoverned` and stopped (`6`). `instrument run` still + holds its grants (article 10). +- **`instrument verify` counts one spawn once.** `subprocess.Popen` creates its process through + `os.posix_spawn` or, from Python 3.14, `_posixsubprocess.fork_exec`, both paths the subprocess + pack names as not interposed, so one governed spawn was reported with an unjudged effect and + `7`. A point may now name, among its `uninterposed_events`, the `inner_events` its own call + raises; such an event is paired with the call it judged when it is that thread's very next event + and carries the same argument vector, and is counted otherwise. The subprocess pack also names + `_posixsubprocess.fork_exec`, which is how `multiprocessing` starts a process by default on Linux + from 3.14, so such a start is counted instead of passing unseen. +- **`instrument verify` answers `3` for a chain read the plane refused**, as every other command + does, rather than `4`; and `7` rather than `4` when it never saw the program's own code start, + since the plane had been asked. `docs/PACKS.md` lists the statuses. +- **Every connection is bounded.** `ask`, every read and the verifier's chain walk wait at most + five seconds for an answer; a far end that accepts and never answers is « could not ask » (`4`) + instead of a command that hangs. A named `--socket` is made absolute once, so a program that + changes directory asks the daemon it was pointed at (with the contract distribution of this + release). +- **Smaller corrections.** A uid with no account name is spelled `user:`, as the daemon names + it, instead of ending `instrument run` in a traceback; an `ask` answer nested past the reads' + depth bound is an answer this client could not read (`4`), not a traceback; `evidence export` + that cannot write its bundle says « could not save » (`7`); on Python 3.14, an `--out` path this + client cannot look at is still refused (`64`) before anything is asked, and an entry + `evidence exports` cannot look at is still « could not check » (`7`); `approvals` names the + person who acted; `packs check` refuses a pack whose `uninterposed_events` or `inner_events` the + verifier would refuse. +- **Interpreter hand-over.** A script whose shebang names a Python that is not there is refused + (`64`) instead of being run by this client's own interpreter; an interpreter newer than the lent + packages declare (3.15 and later) is refused; `--follow-children` under a named interpreter that + does not have this client installed is refused, where every Python child used to die at + start-up; and under `PYTHONSAFEPATH` the head of the import path is left as the interpreter gave + it, where the launcher used to replace an entry of the person's own. +- The full gate runs on Python 3.12, 3.13 and 3.14, the interpreters `requires-python` admits. + - Every commit of a change is read for its sign-off on every pull request, and one that carries none is refused by name. A range that cannot be read is refused too, rather than reported as passing. diff --git a/QUICKSTART.md b/QUICKSTART.md index aa587e2..390cbc4 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,345 +1,450 @@ # Quickstart -Everything on this page was run, in this order, on one machine before it was -written down: the commands are pasted from that run, and so are their answers. -Your identifiers, timestamps and hashes will differ; the shapes will not. +Three commands take a machine from nothing to a real control plane governing a +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 +``` + +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 used +distributions built from this tree and installed by the first command, 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. ## What you need - 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. The walk below used 3.12, the floor. -- [`uv`](https://docs.astral.sh/uv/) to build and install. The walk used 0.12. -- Until the distributions are on an index: a checkout of the control plane's - repository, , beside a - checkout of this one, . Both are - read at their default branch. +- 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 under a directory that a group can write and that is not sticky, so a - run directory under a shared scratch space is refused with - `socket_directory_unprotected` — the walk below met exactly that once, and - moved. - -## 1. Build the distributions and install them + socket below a directory a group can write and that is not sticky + (`socket_directory_unprotected`), and it says so instead of starting. -From a directory of your own — the walk used `~/quickstart`: +## 1. Install ```console -$ mkdir -p ~/quickstart/wheels && cd ~/quickstart -$ (cd /path/to/sayfirst-control-plane && uv build --all-packages --out-dir ~/quickstart/wheels) -$ (cd /path/to/sayfirst-cli && uv build --out-dir ~/quickstart/wheels) -$ uv venv --python 3.12 .venv -$ uv pip install --python .venv/bin/python --find-links wheels sayfirst-cli==0.2.0 sayfirst-control-plane==0.2.0 - + sayfirst-boundary==0.2.0 - + sayfirst-cli==0.2.0 - + sayfirst-contract==0.2.0 - + sayfirst-control-plane==0.2.0 -$ ls .venv/bin | grep '^sayfirst' -sayfirst -sayfirst-daemon +$ 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 +Installed 1 executable from `sayfirst-control-plane`: sayfirst-daemon +Installed 1 executable from `sayfirstd`: sayfirstd +Installed 1 executable: sayfirst ``` -The first build writes fourteen artefacts (seven distributions, a wheel and a -source archive each); the second writes two. Only two distributions are -installed by name: the command you type, and the daemon that answers it. Two -more arrive as dependencies: the contract both share, and the boundary the -command carries for the programs it puts in front of the daemon. The other -distributions the first build produced (a stub, a conformance kit, a testing -kit, the daemon's operator surface) are not needed here. - -`sayfirst --help` lists what the command answers today: +One environment, three commands: `sayfirst` is the client you type, the daemon +is `sayfirst-daemon`, and `sayfirstd` is the daemon's operator surface +(`status`, `whoami`). `uv tool install` takes one package, which is why the +other two ride on `--with-executables-from`; three separate `uv tool install` +lines, or `pip install sayfirst-cli sayfirst-control-plane sayfirstd` in an +environment of your own, install the same thing. + +**What the index holds is not yet what this page installs.** `sayfirst-cli` +0.2.0 is on the index and predates everything below: it has no default socket, +reads `--pack` as a path only, and refuses `python` as the first word of a +target. `sayfirst-control-plane` and `sayfirstd` are not on the index at all as +this is written. Until a release carries this page, install from checkouts of +the two repositories, + and + — which is what the walk did: ```console -$ .venv/bin/sayfirst --help -usage: sayfirst [-h] - {ask,trace,explain,evidence,approvals,instrument,packs} ... +$ (cd /path/to/sayfirst-control-plane && uv build --all-packages --wheel --out-dir ~/wheels) +$ (cd /path/to/sayfirst-cli && uv build --wheel --out-dir ~/wheels) +$ uv tool install --no-index --find-links ~/wheels sayfirst-cli --with-executables-from sayfirst-control-plane --with-executables-from sayfirstd ``` -## 2. Write a policy and a configuration +`--no-index` keeps the install to the wheels you built: nothing is fetched, so +what runs is exactly those two trees — and two of the five distributions are +not on the index to be fetched anyway. -The daemon reads one TOML policy file. This one names two capabilities: one it -allows on its own, one it holds for a person. Put your own account name in -`principals` — the reference is `user:` and the name `id -un` prints — because -the daemon establishes who is asking from the socket, and a rule names who it -is for. +## 2. Start a control plane ```console -$ mkdir -p ~/.sayfirst/quickstart && chmod 700 ~/.sayfirst ~/.sayfirst/quickstart +$ sayfirst-daemon up --quickstart +SayFirst Control Plane ready +mode: per_user +socket: /run/user/1000/sayfirst/daemon.sock +policy: ~/.sayfirst/quickstart/policy.toml +evidence: ~/.sayfirst/quickstart/evidence +integrity grade: observability (the caller can write the store; the chain detects accidental corruption only) +grade re-evaluation interval: 30 seconds +privacy provider: none (captured content is recorded as given) +evidence emission: delivering +log: ~/.sayfirst/quickstart/daemon.log +pid: 1622067 +wrote: ~/.sayfirst/quickstart/policy.toml +wrote: ~/.sayfirst/quickstart/daemon.toml +stop: sayfirst-daemon down ``` -`~/.sayfirst/quickstart/policy.toml`: - -```toml -format = 1 +That is the real daemon — the one `sayfirst-daemon serve --config` starts — +running in the background, in per-user mode, on two files it wrote because they +were not there. « Ready » is said only after the daemon has **answered** a +status request over its socket, and the four lines from `integrity grade:` down +are that answer, in the daemon's own words. The grade is `observability` +because in per-user mode you can write the store you are asking about; the +quickstart does not get to say anything better than the daemon does. -[revision] -reason = "the first policy of this quickstart" +Everything lives in one private directory (`0700`, files `0600`): -[[rule]] -id = "read-runs-on-its-own" -capability = "example.read" -principals = ["user:faime"] -outcome = "allow" -reason = "reading is reversible" - -[[rule]] -id = "send-waits-for-a-person" -capability = "example.send" -principals = ["user:faime"] -outcome = "suspend" -reason = "a message that leaves the machine waits for a person" -review_deadline_seconds = 600 +```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 ``` -`~/.sayfirst/quickstart/daemon.toml` — per-user mode, no socket path: the -daemon chooses its default address, `$XDG_RUNTIME_DIR/sayfirst/daemon.sock` -where that variable is set and `~/.sayfirst/run/daemon.sock` otherwise, and -creates every level of the directory at `0700`: +`daemon.run.json` is what `up` knows about the daemon it started: its pid, the +instant the kernel says that process began (tied to this boot), and the socket, +policy and evidence it was started on. `daemon.lock` is taken by `up` and `down` +while they read that record, start or stop the daemon, and write the record +back, so two of them never race; the daemon itself does not hold it. -```toml -[socket] -mode = "per_user" +**`policy.toml` is yours.** Open it: it is commented TOML that says what a rule +is and what the three outcomes mean, and it starts with three behaviours so +that each can be seen without an edit — starting a process (`process.spawn`) +is allowed, opening a URL (`net.egress`, what `--pack http-client` asks about) +waits for a person, and opening a database (`database.open`) is named by no +rule and is therefore denied. It names your account, because a rule is for the +principals it names; the daemon learns who is asking from the socket, never +from the file. -[policy] -path = "/home/faime/.sayfirst/quickstart/policy.toml" +**Running the command again never writes over it.** A second `up --quickstart` +finds the daemon it started and says `SayFirst Control Plane already running`; +after a `down` it starts again on the files as you left them. A file is only +ever written when it is missing — delete `policy.toml` to get the starter back. +A policy the daemon cannot read is the daemon's own refusal +(`policy_unavailable_at_start`, exit `78`), never a reason to replace your file. -[evidence] -path = "/home/faime/.sayfirst/quickstart/evidence" -``` - -Both files are yours alone: +## 3. Govern a program -```console -$ chmod 600 ~/.sayfirst/quickstart/policy.toml ~/.sayfirst/quickstart/daemon.toml -``` +`my_agent.py`, in a directory of its own: -## 3. Start the daemon +```python +import subprocess -In a terminal of its own; it announces where it listens on its first line: +subprocess.run(["echo", "hello"], check=True) +``` ```console -$ .venv/bin/sayfirst-daemon serve --config ~/.sayfirst/quickstart/daemon.toml -serving per_user at /run/user/1000/sayfirst/daemon.sock (acl: checked) +$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +hello ``` -Every command below takes that address as `--socket`; the walk kept it in a -variable, and so should you, with the address your daemon announced: +The program ran with the boundary in front of `subprocess`: before the process +was started the control plane was asked, it answered `allow`, and it recorded +that. Three things in that line are worth a sentence each. + +- **`--pack subprocess` is an instrumentation pack, not a policy.** A pack says + *which calls are asked about* — here, starting a process, under the + capability `process.spawn`. What the answer is belongs to the policy, in the + daemon. A bare word names a pack this distribution ships (`sayfirst packs + list`: `database`, `http-client`, `subprocess`); a pack of your own is a + directory, spelled with a separator: `--pack ./my-pack`. +- **No `--socket`.** A per-user daemon given no address serves at + `$XDG_RUNTIME_DIR/sayfirst/daemon.sock` (or `~/.sayfirst/run/daemon.sock` + where there is no runtime directory), and a client given none looks at that + 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 + 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: ```console -$ S=/run/user/1000/sayfirst/daemon.sock +$ sayfirst instrument run --pack subprocess --scope local -- python real_agent.py +hello from the project's own interpreter +running under ~/project/.venv +imported: a dependency only the project has ``` -## 4. Ask + An agent started as a console script rather than as `python …` 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. -A capability the policy allows answers `allow` and exits `0`: +What the control plane recorded, read back from it: ```console -$ .venv/bin/sayfirst ask --capability example.read --scope local --socket $S +$ sayfirstd status verified: true (server_uid 1000, expected 1000) -outcome: allow -reason: policy_allows -capability: example.read in scope local -decision: 0acbb683-ea96-4f1c-9923-c492b4f0cb16 at 2026-09-16T23:15:32.289304Z -policy version: sha256:b7991fcab7f96d4af66efc769874ede67414efae3adadcfd3382b9f79754c724 +contract generation: 1 (supported: 1) +integrity grade: observability (the caller can write the store; the chain detects accidental corruption only) +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 2b12918b5588b2e3b50efa80fb7035ead2d2579537d991118f92b05bc0c0cfb3 +2 grade b5b86aba-5d1b-4b47-8059-38783a488547 7f6c25dfac3eac4ee6174aff24630eaf8f8804d96436a0daf160644e8195806f +3 grade 7750b836-8ee2-4d2b-8de5-2c9d96b16e75 ca6a3b37ba461f63b70ae8b6d16641c4b1d446be7fbb428a9d50db38c2c55d1e +4 grade 0db649e3-d5c9-4145-b05a-feb2ed6c1344 23ca566a49a282624fc21c1a65f467c2d437350682d0ad6dfe131e429f76fdc7 +5 effect 0db649e3-d5c9-4145-b05a-feb2ed6c1344 13776e23385f7a6fa0aff7a01af3a3e090ea3be27a7e9aeab83f93cb09dee791 +… +next_from: none ``` -The first line says the daemon proved who it is: the socket's owner is the -account you expected. A capability the policy holds for a person answers -`suspend`, names the wait, and exits `5`: +Each `effect` line is one decision about one governed call. + +## 4. Change the policy + +Open `~/.sayfirst/quickstart/policy.toml`, find the rule for `process.spawn`, +and change one word — `outcome = "allow"` to `outcome = "deny"`. Save. Nothing +is restarted; the daemon reads the file when it decides. ```console -$ .venv/bin/sayfirst ask --capability example.send --scope local --socket $S -verified: true (server_uid 1000, expected 1000) -outcome: suspend -reason: policy_requires_review -capability: example.send in scope local -decision: b6da8ac3-145f-4084-ac6c-74cf64ae7aff at 2026-09-16T23:15:32.366325Z -policy version: sha256:b7991fcab7f96d4af66efc769874ede67414efae3adadcfd3382b9f79754c724 -approval: 43fda5a9-9a0e-4f6d-891f-5e24aa4d093c +$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +Traceback (most recent call last): + … +sayfirst_boundary.errors.Denied: denied: process.spawn (policy_denies, 48293af4-5ab0-4c92-b16b-f0ca974a490b) +$ echo $? +1 ``` -Nothing was executed. Asking again while the wait is open returns the same -approval reference, not a second wait. +No `hello`: the process was never started. The refusal is an exception raised +inside your program, at the call — yours to catch. A program that catches it +ends however it chooses; one that catches nothing ends with its traceback, and +`instrument run` then ends with that outcome's own status — `1` for a denial — +rather than the interpreter's `1` for any exception at all, which would read a +suspension or an unreachable control plane as a denial too. + +Now `outcome = "suspend"`: -## 5. Answer as a person +```console +$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +Traceback (most recent call last): + … +sayfirst_boundary.errors.Suspended: suspended: process.spawn awaits approval 55fc6d74-275b-4250-9a7f-58871bfc5bf1 +$ echo $? +5 +``` -Read the wait, then end it — once, with a reason if you want one recorded: +Nothing ran, and a person is being waited for. Read the wait, then end it: ```console -$ A=43fda5a9-9a0e-4f6d-891f-5e24aa4d093c -$ .venv/bin/sayfirst approvals show --approval $A --scope local --socket $S -approval: 43fda5a9-9a0e-4f6d-891f-5e24aa4d093c -decision: b6da8ac3-145f-4084-ac6c-74cf64ae7aff +$ sayfirst approvals show --approval 55fc6d74-275b-4250-9a7f-58871bfc5bf1 --scope local +approval: 55fc6d74-275b-4250-9a7f-58871bfc5bf1 +decision: 45faca76-852a-4991-a5fb-bc2190e42325 state: pending -requested_at: 2026-09-16T23:15:32.366325Z -deadline: 2026-09-16T23:25:32.366325Z +requested_at: 2026-09-23T02:05:45.453931Z +deadline: 2026-09-23T02:10:45.453931Z resolved_at: not stated reason: not stated -$ .venv/bin/sayfirst approvals approve --approval $A --scope local --socket $S --reason "checked by hand" -approval: 43fda5a9-9a0e-4f6d-891f-5e24aa4d093c -decision: b6da8ac3-145f-4084-ac6c-74cf64ae7aff +$ sayfirst approvals approve --approval 55fc6d74-275b-4250-9a7f-58871bfc5bf1 --scope local --reason "checked by hand" +approval: 55fc6d74-275b-4250-9a7f-58871bfc5bf1 +decision: 45faca76-852a-4991-a5fb-bc2190e42325 state: approved -requested_at: 2026-09-16T23:15:32.366325Z -deadline: 2026-09-16T23:25:32.366325Z -resolved_at: 2026-09-16T23:15:43.269197Z +requested_at: 2026-09-23T02:05:45.453931Z +deadline: 2026-09-23T02:10:45.453931Z +resolved_at: 2026-09-23T02:05:45.642267Z reason: checked by hand +person: user:you +$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py +hello ``` -The deadline is the policy's `review_deadline_seconds` after the ask. The -same question, asked again, is now allowed — with the reason that says why: +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`. + +## 5. Prove it + +Put the rule back to `"allow"`, and ask for proof rather than a run: ```console -$ .venv/bin/sayfirst ask --capability example.send --scope local --socket $S -verified: true (server_uid 1000, expected 1000) -outcome: allow -reason: approval_granted -capability: example.send in scope local -decision: bcfc5e6e-1c85-4637-9d4a-2373fd4d4995 at 2026-09-16T23:15:43.331201Z -policy version: sha256:b7991fcab7f96d4af66efc769874ede67414efae3adadcfd3382b9f79754c724 -approval: 43fda5a9-9a0e-4f6d-891f-5e24aa4d093c +$ sayfirst instrument verify --pack subprocess --scope local -- python my_agent.py +hello +governed subprocess subprocess.Popen process.spawn events=1 +inspected: subprocess +target exit: 0 +$ echo $? +0 ``` -That allow is the one execution the person's act authorised. `approvals -reject` ends a wait the other way, and the next ask of that question is a -`deny` with reason `approval_rejected`. +`verify` runs the program under the interpreter's own audit hook and checks each +effect of a kind the pack names against the chain the daemon kept. `governed` +says exactly one thing: every such effect this run made was preceded by a +recorded `allow`, for your account, after the run began. It is not a claim +about paths the run did not take — a point no effect reached is `not-exercised` +and exits `7`, never `0` — nor about calls the pack does not interpose; +[`docs/PACKS.md`](docs/PACKS.md) states what the proof matches and what it does +not. The program's own output arrives on the error stream, so the report is +alone on stdout. -## 6. Two answers that are not permission - -A capability no rule names is denied, and the reason says so — the policy is -absent for it, not consulted and silent: +## 6. Stop, and what a stopped control plane means ```console -$ .venv/bin/sayfirst ask --capability example.delete --scope local --socket $S -verified: true (server_uid 1000, expected 1000) -outcome: deny -reason: policy_absent -capability: example.delete in scope local -decision: 3a0a514a-3215-4284-a772-07fd6c8a10ec at 2026-09-16T23:15:56.548057Z -policy version: sha256:b7991fcab7f96d4af66efc769874ede67414efae3adadcfd3382b9f79754c724 -$ echo $? -1 +$ sayfirst-daemon down +SayFirst Control Plane stopped (pid 1622067) +policy and evidence are kept under ~/.sayfirst/quickstart ``` -A daemon that is not there is reported as its own kind of answer, exit `4`, -never as a denial and never as permission: +`down` stops the daemon `up` started and no other: it signals the recorded +process only when that process still began at the recorded instant, as the +kernel reports it for this boot. A record whose process is gone is removed and +nothing is signalled; a living process whose start this command cannot read is +left running, with its record, and `down` says so and exits `1`; a daemon you +started yourself with `serve` is left running, and `down` says so. The daemon +closes its evidence epoch on the way out, and removes its socket. + +With no control plane, nothing is permitted and nothing is called a denial: ```console -$ .venv/bin/sayfirst ask --capability example.read --scope local --socket /run/user/1000/sayfirst/absent.sock +$ 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 +$ 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) 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 +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): + … +sayfirst_boundary.errors.CouldNotAsk: could not ask: [Errno 2] No such file or directory +$ echo $? +4 ``` -The exit codes, in one line: `0` allow, `1` deny, `5` suspend, `3` the request -was refused, `4` the control plane could not be asked. +The effect did not happen. A governed program with nobody to ask fails closed, +ends « could not ask », and the address nobody typed is named so that you know +where it looked. + +The exit codes of `sayfirst ask`, in one line: `0` allow, `1` deny, `5` +suspend, `3` the request was refused, `4` the control plane could not be asked — +and `instrument run` ends with the same numbers when a program lets the outcome +escape. -## 7. Read back what happened +## 7. Asking by hand, and reading back -The evidence chain is paged from a sequence number; `--all` follows every -page. Each line is a sequence, a kind, the connection it belongs to and the -entry's hash: +The same three outcomes without a program. `--scope` defaults to `local` for +`ask`, and every other read names it: ```console -$ .venv/bin/sayfirst evidence history --scope local --socket $S --from 1 --all -1 composition daemon e81dcf376977f664fc207ed64e04b5212c6b5052d759a77d288ec0265de13610 -2 grade a6f0ddb7-98ba-4508-aebe-b999b37854d3 39d7dbb7f55fcec0ad717c61d0efb85da3f1bf957a9457aa331fa81e729a1a5f -3 effect a6f0ddb7-98ba-4508-aebe-b999b37854d3 8d74bf759adf55eceb69e1ac95b3bd4b5d83201764bf8e3173dcb67300aee40e -… -next_from: none +$ sayfirst ask --capability process.spawn +verified: true (server_uid 1000, expected 1000) +outcome: allow +reason: policy_allows +capability: process.spawn in scope local +decision: 52ff1ab5-8813-4230-9ca4-2f272612c64d at 2026-09-23T02:05:47.180188Z +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: 5cb90b68-bb1a-4674-ae2d-97a1bd9c5797 at 2026-09-23T02:05:47.255248Z +policy version: sha256:efc1746cea9b50cb31aa5c9f08de996cbefabc34a09d4250d8b68f67c709a5dc +approval: d058eee2-4ecc-488c-837d-037e206c1247 +$ sayfirst ask --capability database.open +verified: true (server_uid 1000, expected 1000) +outcome: deny +reason: policy_absent +capability: database.open in scope local +decision: c6284ad7-f77b-4367-a50d-977489e2f6f7 at 2026-09-23T02:05:47.335332Z +policy version: sha256:efc1746cea9b50cb31aa5c9f08de996cbefabc34a09d4250d8b68f67c709a5dc ``` -Every read is itself recorded: `history`, `explain`, `trace` and `export` -each append an entry to the chain, so the counts on this page hold for this -page's exact sequence of commands, and one extra command of yours adds one -entry. Nothing is lost by that; it is the chain doing its job. +The first line of each says the daemon proved who it is: the socket's owner is +the account you expected. The three exit `0`, `5` and `1`. The denial's reason +is `policy_absent`: no rule names the capability, which is not the same as a +rule saying no. -One decision, explained in the daemon's own words — the rule it applied and -the policy version it ran under — and traced into the chain that holds it: +One decision, explained in the daemon's own words and traced into the chain: ```console -$ D=0acbb683-ea96-4f1c-9923-c492b4f0cb16 -$ .venv/bin/sayfirst explain --scope local --socket $S --decision $D -decision_ref: 0acbb683-ea96-4f1c-9923-c492b4f0cb16 +$ sayfirst explain --scope local --decision 474856fc-e891-46ac-844c-efa30cc9322f +decision_ref: 474856fc-e891-46ac-844c-efa30cc9322f scope: local -capability: example.read +capability: process.spawn outcome: allow reason: policy_allows -rule_id: read-runs-on-its-own -policy_version: sha256:b7991fcab7f96d4af66efc769874ede67414efae3adadcfd3382b9f79754c724 -decided_at: 2026-09-16T23:15:32.289304Z +rule_id: local-processes-run +policy_version: sha256:efc1746cea9b50cb31aa5c9f08de996cbefabc34a09d4250d8b68f67c709a5dc +decided_at: 2026-09-23T02:05:47.410354Z … -principal: {'kind': 'user', 'name': 'faime', 'uid': 1000} -principal_references: ['user:faime'] -$ .venv/bin/sayfirst trace --scope local --socket $S --decision $D | tail -1 -chain: sequence 3, entry_hash 8d74bf759adf55eceb69e1ac95b3bd4b5d83201764bf8e3173dcb67300aee40e, grade observability +$ sayfirst trace --scope local --decision 474856fc-e891-46ac-844c-efa30cc9322f | tail -1 +chain: sequence 34, entry_hash 0d00934b16b3dc0004d7e13c884b83aec61a79dfb6d43278dab3530d1f8250d7, grade observability ``` -## 8. Export, and verify offline +Every read is itself recorded: `history`, `explain`, `trace` and `export` each +append an entry to the chain, so one extra command of yours adds one entry. -A bundle taken while the daemon's current epoch is open is checked as far as -it can be, and says so: the chain is intact and the manifest recomputes, but -coverage is `unknown`, and the command exits `7`, which means « could not -check », not « broken »: +A bundle exported while the daemon's current epoch is open is checked as far as +it can be and says so — the chain is intact and the manifest recomputes, but +coverage is `unknown`, and the command exits `7`, « could not check », not +« broken »: ```console -$ .venv/bin/sayfirst evidence export --scope local --socket $S --from 1 --out bundle.json +$ sayfirst evidence export --scope local --from 1 --out bundle.json local_check: unverifiable manifest: recomputes chain: intact coverage: unknown issue: coverage_unknown verification: intact -saved: bundle.json (12 entries) +saved: bundle.json (36 entries) ``` -Stop the daemon cleanly (`Ctrl-C`, or `kill -TERM` on its process); it closes -the epoch and removes its socket. Start it again and export the range that -ends at the closing marker — here sequence 13, the entry after the last -effect — and coverage is `complete`: +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. -```console -$ .venv/bin/sayfirst evidence export --scope local --socket $S --from 1 --to 13 --out closed.json -local_check: unverifiable -manifest: recomputes -chain: intact -coverage: complete -verification: intact -saved: closed.json (13 entries) -``` +## 8. A control plane of your own -The local check still answers `unverifiable`, and `--json` says exactly why: -three of the four decisions re-derive from the policy alone and are -`confirmed` — the allow, the suspension and the denial — while the fourth, -the allow a person granted, has the cause `reason_outside_recipe`. A -decision a person made cannot be re-derived from a policy file, and the -verifier says so rather than counting it. That is the honest verdict, not a -fault, and it is what a third party sees too: `sayfirst evidence exports -DIRECTORY` re-verifies every bundle saved in a directory with the contract -distribution alone, no daemon needed. +The quickstart is a convenience over one command, and everything it does can be +done by hand — which is what a deployment does, under its own supervisor: ```console -$ .venv/bin/sayfirst evidence exports . -bundle.json local 1..12 served:intact local:unverifiable -closed.json local 1..13 served:intact local:unverifiable +$ sayfirst-daemon serve --config /path/to/daemon.toml +serving per_user at /run/user/1000/sayfirst/daemon.sock (acl: checked) ``` -An export refuses to overwrite a bundle that exists (`refusing to overwrite`, -exit `64`); name a new file. +`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`: -## 9. Stop, and where this goes next +```console +$ sayfirst ask --capability process.spawn --socket /srv/sayfirst/daemon.sock +$ sayfirst instrument run --pack /srv/packs/own-pack --scope local --socket /srv/sayfirst/daemon.sock -- app.py +``` -`kill -TERM` the daemon, or `Ctrl-C` in its terminal. It leaves the socket -directory empty and the evidence under the path the configuration named. +System mode — one daemon, several accounts admitted by a group — is described +in the control plane's `docs/deployment.md`. A system profile always names both +the socket and the account the daemon runs as (`--mode system --socket PATH +--daemon-user NAME`); neither is ever defaulted, because a default there would +let a profile written for one host verify the wrong thing on another. -- `sayfirst packs list` prints the three convenience packs this distribution - ships — `database`, `http-client`, `subprocess` — and `sayfirst instrument - run` puts the boundary in front of a program whose effects are library - calls; the README describes both. -- A program can compose the boundary by hand from the `sayfirst-boundary` - distribution instead. The governed-agent demonstration does exactly that, - against this same daemon. -- The control plane's `docs/deployment.md` describes system mode, where one - daemon serves several accounts admitted by a group. +A program can also compose the boundary by hand from the `sayfirst-boundary` +distribution instead of being launched by `instrument run`; the governed-agent +demonstration does exactly that, against this same daemon. diff --git a/README.md b/README.md index 9258f0c..13ec0a3 100644 --- a/README.md +++ b/README.md @@ -34,60 +34,78 @@ flowchart LR V[anyone, offline
sayfirst evidence exports] -. "verify the chain" .-> D ``` -## Install - -`sayfirst-cli` 0.2.0 is on the Python index. As a tool, in an environment of -its own: +## Three commands ```console -$ uv tool install sayfirst-cli==0.2.0 - + sayfirst-boundary==0.2.0 - + sayfirst-cli==0.2.0 - + sayfirst-contract==0.2.0 -Installed 1 executable: sayfirst +$ 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 ``` -Three distributions arrive and no more: the command, the contract it speaks, and -the boundary in which a governed program holds its grant. Never a web framework, -never a database layer — article 14's direction of dependency, measured on a -real install by `scripts/check_dependency_closure.py` rather than promised here. -`sayfirst-contract-stub` (a scriptable fake) and `sayfirst-conformance` are on -the index at 0.2.0 as well. - -The daemon that answers is `sayfirst-control-plane`, and it is **publishing** — -not on the index as this is written. Build it from a checkout of -[the control plane's repository](https://github.com/fredaime/sayfirst-control-plane), -the way [`QUICKSTART.md`](QUICKSTART.md) does: +The first installs the client (`sayfirst`), the daemon (`sayfirst-daemon`) and +the daemon's operator surface (`sayfirstd`) into one tool environment — `uv tool +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 +process it starts asked about first: ```console -$ (cd /path/to/sayfirst-control-plane && uv build --all-packages --out-dir ~/quickstart/wheels) -$ uv pip install --python .venv/bin/python --find-links wheels sayfirst-cli==0.2.0 sayfirst-control-plane==0.2.0 +$ sayfirst-daemon up --quickstart +SayFirst Control Plane ready +mode: per_user +socket: /run/user/1000/sayfirst/daemon.sock +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 +hello ``` -## Try it in five minutes - -Linux or macOS, Python 3.12, 3.13 or 3.14, and -[`QUICKSTART.md`](QUICKSTART.md), which walks one governed decision end to end: -a policy of two rules, an allow, a suspension, a person's answer, then the chain -read back and exported. Every command on that page was run, in that order, before -it was written down; its answers are pasted from that run. Two of them: +Nothing in those lines is told where anything is. A per-user daemon given no +address serves at `$XDG_RUNTIME_DIR/sayfirst/daemon.sock` (`~/.sayfirst/run/` +where there is no runtime directory), and a client given no `--socket` looks at +that one name — it searches for nothing, and it still verifies that whoever +answers is your own account's process before it sends a byte. `--pack +subprocess` is an **instrumentation pack**, not a policy: it says which calls +are asked about, and the daemon's policy says what the answer is. + +**The policy is a file you edit**: `~/.sayfirst/quickstart/policy.toml`. +Change `outcome = "allow"` to `"deny"` in its `process.spawn` rule and the same +run stops before the process starts (status `1`); make it `"suspend"` and the +run stops there too (status `5`) while the request waits for a person — +`sayfirst approvals approve` ends the wait, and the next run of the same program +goes through, once. The daemon reads the file when it decides, so nothing is +restarted — and running `up --quickstart` again never writes over it. ```console -$ .venv/bin/sayfirst ask --capability example.read --scope local --socket $S -verified: true (server_uid 1000, expected 1000) -outcome: allow -reason: policy_allows -$ .venv/bin/sayfirst evidence export --scope local --socket $S --from 1 --out bundle.json -local_check: unverifiable -manifest: recomputes -chain: intact -coverage: unknown -issue: coverage_unknown +$ sayfirstd status # what the daemon says about itself +$ sayfirst instrument verify --pack subprocess --scope local -- python my_agent.py +$ sayfirst-daemon down # stops the daemon `up` started, and only that one ``` -The `verified:` line is the daemon proving who it is. The second command is the -honesty: that bundle's chain is intact and its manifest recomputes, and it still -answers `unknown`, because its epoch is open. +[`QUICKSTART.md`](QUICKSTART.md) walks all of it — allow, deny, a suspension and +a person's answer, the proof, the chain read back and exported — and every +command on that page was run, in that order, before it was written down. + +### What the index holds today + +`sayfirst-cli` 0.2.0 is on the Python index, with `sayfirst-contract`, +`sayfirst-boundary`, `sayfirst-contract-stub` and `sayfirst-conformance`. That +release **predates the three commands above**: it requires `--socket`, reads +`--pack` as a directory only, and refuses `python` as the first word of a +target. `sayfirst-control-plane` and `sayfirstd` are **not on the index** as +this is written. Until a release carries this page, build the distributions from +checkouts of the two repositories and install those — the quickstart's first +section gives the three lines, and they are what its walk used. + +Installed on its own, the client brings three distributions and no more: the +command, the contract it speaks, and the boundary in which a governed program +holds its grant. Never a web framework, never a database layer — article 14's +direction of dependency, measured on a real install by +`scripts/check_dependency_closure.py` rather than promised here. ## What you get — three directions @@ -101,14 +119,17 @@ verified, and one answer rendered as it was given: allow, deny or suspend, and "could not ask" when the daemon could not be reached. That is `sayfirst ask`. **It puts the boundary in front of somebody else's program.** `sayfirst -instrument run --pack DIR … -- ` runs a program with the named effects -asked about first, reversibly and writing nothing anywhere; `instrument verify` +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 -distribution ships — `database`, `http-client`, `subprocess` — with the path -`--pack` accepts ([`docs/PACKS.md`](docs/PACKS.md)). A program whose effects are +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 not library calls composes the boundary by hand from `sayfirst-boundary`. **Afterwards, it reads and verifies.** `sayfirst trace` reads back one decision @@ -132,13 +153,17 @@ acted, when and why. `approve` and `reject` end it exactly once, with an optiona plane could not be asked — because article 1 requires that "denied" and "could not ask" never read as each other. A caller branching on a single non-zero exit would read an unreachable daemon as a refusal; the codes exist so that it -cannot. `trace`, `explain` and `evidence history` exit `0` on a read, whatever -the record said. The checks — `evidence audit`, `export`, `exports` and -`instrument verify` — carry the local check's own result instead: `0` when it -holds, `6` for a finding the plane did not state, `7` when it could not -conclude, the ordinary answer for a bundle from an open epoch (so `sayfirst -evidence export && …` is not how to script one). A misused invocation is `64`; -a usage error is `2` everywhere. +cannot. `instrument run` ends with the program's own status — except when the +program does not handle an outcome the boundary raised in it, and then it ends +with that outcome's code (`1`, `5`, `3` or `4`) rather than the interpreter's +`1`, which would read every one of them as a denial. `trace`, `explain` and +`evidence history` exit `0` on a read, whatever the record said. The checks — +`evidence audit`, `export`, `exports` and `instrument verify` — carry the local +check's own result instead: `0` when it holds, `6` for a finding the plane did +not state, `7` when it could not conclude, the ordinary answer for a bundle from +an open epoch (so `sayfirst evidence export && …` is not how to script one); +like every read, they answer `3` or `4` when the plane refused them or could not +be asked. A misused invocation is `64`; a usage error is `2` everywhere. ## What it is not (yet) @@ -173,10 +198,10 @@ instrumentation engine with its three packs, and the offline verifier that reads the contract's canonicalization, recipe and vectors — no line of server code. **Where the line falls.** The control plane's repository keeps the operator -surface that inspects its own daemon — `status`, `doctor`, `policy -show|history`, `plugins list`, with `systems {register,retire}` pending its own -question. `trace`, `explain` and `evidence {audit,history,exports,export}` are -this client's: they read the decisions and evidence the plane supplied. +surface that inspects its own daemon, `sayfirstd` — today `status`, `whoami`, +`plugins list` and `conformance replay`. `trace`, `explain` and +`evidence {audit,history,exports,export}` are this client's: they read the +decisions and evidence the plane supplied. [`docs/PARTITION.md`](docs/PARTITION.md) says it command by command, and names the questions it does not close. @@ -201,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.2.0 ./scripts/gate.sh +$ SAYFIRST_CONTRACT_SOURCE=../sayfirst-control-plane SAYFIRST_CONTRACT_REF=v0.3.0 ./scripts/gate.sh ``` The workflow does the same and carries no credential of any kind: a fork can diff --git a/docs/PACKS.md b/docs/PACKS.md index bf1a3d5..dc3c524 100644 --- a/docs/PACKS.md +++ b/docs/PACKS.md @@ -50,20 +50,44 @@ engine loads `interpose.py` through a loader with the bytecode write taken out, so a pack on a read-only path works and a pack on a writable one is left as it was found.) -A pack is **designated by path**. `sayfirst instrument run --pack DIR …` -names the directory directly, the same way a person names a file to run. There -is no search path this client consults on its own, no default set of packs it -installs unless told to, and no name a pack is resolved from — `--pack` takes -a path and nothing shorter. `sayfirst packs list` and `sayfirst packs check` -read the packs this distribution ships beside it, but neither is consulted by -`instrument run`, which reads only the paths a person types. +A pack is **designated by the person who runs it**, in one of two spellings, +and which one applies is decided by the spelling alone — before the file system +is looked at, so the same words mean the same thing in every directory. + +* **A path is a directory.** A designation with a path separator in it — and + `.` and `..` — names the directory directly, the same way a person names a + file to run: `--pack ./own-pack`, `--pack /srv/packs/own-pack`. +* **A bare word is the name of a pack this distribution ships**: + `--pack subprocess`, `--pack http-client`, `--pack database`. It is looked up + in one place, the package data installed beside this client — the set + `sayfirst packs list` prints — and a word that names nothing shipped is + refused, saying what is shipped and how a directory is spelled. + +That is a designation and not a registry, because everything a registry would +be is absent on purpose. There is no search path: one location is read and it +is not configurable — no environment variable, no file, no directory of the +user's. There is no fallback between the two readings: a bare word is **never** +tried as a directory of the working directory, so a directory somebody left +beside a program cannot become the code that runs in front of it, and a path is +never tried as a name. There is no default set: a pack nobody designated is not +installed. And there is no name anybody else can publish under: the set is +closed by what this distribution carries, and a pack of one's own is designated +by its path. `src/sayfirst_cli/instrument/designation.py` is the whole of the +rule, and `tests/test_pack_designation.py` holds both halves and the two ways +they could leak into each other. + +(This replaces an earlier, stricter statement — « `--pack` takes a path and +nothing shorter » — under which the path of a shipped pack had to be copied out +of `sayfirst packs list`: an installation's site directory typed by hand. What +that statement protected is kept word for word above; what it cost was that the +first command a person ran named a directory inside somebody's environment.) ## The packs this distribution ships | name | capability | classified | what it governs | |---|---|---|---| | `subprocess` | `process.spawn` | 2026-09-15 | `subprocess.Popen` — and, because `subprocess.run`, `subprocess.call` and `subprocess.check_output` all spawn through the module's own `Popen`, all four call shapes with it | -| `http-client` | `net.egress` | 2026-09-15 | `urllib.request.urlopen` — the one function every scheme `urllib.request` handles goes through (`http`, `https`, `file`, `data` alike), so `file:` and `data:` reads are asked under this capability too, since the interpreter offers no narrower seam; the URL in the digest is what distinguishes them. The capability names the effect — a request leaving the process — and borrows no segment from the module or the attribute. The verifier keys on `urllib.Request`. | +| `http-client` | `net.egress` | 2026-09-15 | `urllib.request.urlopen` — the one function every scheme `urllib.request` handles goes through (`http`, `https`, `file`, `data` alike), so `file:` and `data:` reads are asked under this capability too, since the interpreter offers no narrower seam; the URL in the digest is what distinguishes them. The capability names the effect — a request leaving the process — and borrows no segment from the module or the attribute. The verifier keys on `urllib.Request`. **One call is not always one request**: an address that answers `302` makes a second, and the redirect handler makes it through the opener it is attached to rather than by calling `urlopen` again — so for the length of one governed call the opener's own `open` is asked about too, once per request, with the address that hop resolved. That is the arithmetic the verifier already does, one consultation per `urllib.Request`, so a redirect of any length, and one that moves host or scheme, is asked about as many times as the interpreter reports it. A request made on an opener directly, outside any `urlopen`, is not asked about — the verifier watching the same events says so as a finding rather than this pack pretending otherwise. | | `database` | `database.open` | 2026-09-15 | `sqlite3.connect` — opening a database. The verifier keys on `sqlite3.connect`. | This table is held against the distribution by @@ -72,10 +96,10 @@ that does not, and the classification date read from each pack's own `pack.toml`. A table maintained by hand is a claim; this one fails the gate when it stops matching. -Each is installed the same way: `sayfirst instrument run --pack - …`. `sayfirst packs check PATH` reads -one pack the way the engine will and says whether it is valid, without running -anything. `sayfirst packs list` prints one line per pack — ` +Each is installed the same way: `sayfirst instrument run --pack …`. +`sayfirst packs check PACK` takes the same two spellings `--pack` does, reads +that one pack the way the engine will and says whether it is valid, without +running anything. `sayfirst packs list` prints one line per pack — ` ` — and a pack whose manifest does not read is named on the error stream with the member and the rule it broke, the rest of the list is printed anyway, and the command exits `64`. It never omits a broken pack in @@ -83,7 +107,7 @@ silence, and it never ends in a traceback. ## Verifying -`sayfirst instrument verify --pack DIR … --socket P --scope S -- ` +`sayfirst instrument verify --pack PACK … --scope S -- ` proves one thing about one run: **every effect of a kind a designated pack names was preceded by a decision.** It proves it from two sources and no third — the events the interpreter itself reports (`sys.addaudithook`) and the @@ -130,15 +154,22 @@ counted, and leaves no `unjudged` behind it. `src/sayfirst_cli/instrument/launch.py` records the stretch, its measured width and why it is measured rather than guarded. -A run can also report **no verdict at all**, and exits `4` when it does: the -chain could not be read — before the program started, or while it ran — or the +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). 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. +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. +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 +the class of the problem beside its code, because the same code is minted on +both sides of the wire and only the value says which side it came from. A report that arrives and cannot be read exits `7`, the code for a check that could not conclude: the findings exist and this client could not read them, @@ -150,7 +181,7 @@ program, a pack that will not read, a point the engine will not install — and profile this command refuses before a second interpreter is started. No question was ever put, so none of these is a verdict, and each is the same `64` `sayfirst instrument run` gives the very same mistake. That is the whole of what -this verb assigns: `0`, `6`, `7`, `4`, `64` — a usage error is the parser's `2`, as +this verb assigns: `0`, `6`, `7`, `3`, `4`, `64` — a usage error is the parser's `2`, as the README says of every command — and `tests/test_packs_shipped.py` holds this section against `exit_codes.CODES` in both directions. @@ -178,6 +209,196 @@ plane's end-to-end gate, not this repository's: the contract's fake serves placeholder evidence pages by its own admission, so a run against it can prove the hand-off and the report's shape and nothing about a chain. +## Matching + +A verification is two sources against each other: the events the interpreter +reported, and the records the chain holds. **Matching** is the rule that says +which record answers for which event, and it is the rule the whole verdict rests +on — a verifier that accepts the wrong record has proven that a decision exists +somewhere in the scope, never that one preceded the effect it watched. + +A record answers for an observed effect when all of the following hold, and it +is stepped over when any of them does not. + +* It is an entry of kind `effect`, of the capability the point declares, whose + outcome the plane recorded as `allow`. +* It sits **after the position the chain had before the program started**. A + record already there when the run began is not a record this run produced. +* It has not already been **spent**. One recorded decision answers for one + effect and never for a second — and what is spent is what was *used*, never + what a walk stepped over on the way. A walk that spent what it skipped made + every record behind the one it took unreachable, so an effect whose decision + was sitting in the chain was reported as a finding against it; the chain is + written in decision order and a program runs in execution order, and the two + are not the same order. **Which is why a verifying run holds no grant.** + Under `instrument run` the boundary keeps what it was granted (article 10), + and an identical effect repeated while that grant lives is answered by it + with nothing asked and nothing recorded. Under `verify` the boundary in front + of the program asks for every effect, so every effect has a record of its + own: a program that spawns the same command twice is two decisions there, + and one there would read the second spawn as ungoverned. +* It was **recorded for the account this process runs as**. Identity is the + operating system's (article 6): the daemon builds a principal from the peer + credential of the connection that asked, and the peer of a governed program's + boundary is the process being verified. A record of another principal is + somebody else's decision, it answers for nothing here, and it is said on the + error stream so that a reader who can see an allow in the chain is not left + wondering why it answered for nothing. +* It comes out of a range **the chain's own verification reports `intact`**. + The daemon serves its reading of the chain beside the page; three of the four + conditions it can report are a break, a declared gap and « could not tell », + and a record read out of any of them is evidence the writer of the chain + declined to stand behind. Taking one is a claim stronger than the evidence + held (article 2), so it is not taken, and the effect is **not judged** rather + than found against: a damaged chain is not an established absence. +* Where this run's own boundary is what asked — the governed hand-off, which is + the shipped mode — it carries this run's **correlation**: a token the verifier + generates before the program starts and the boundary stamps on every ask, which + the plane records on the effect (`correlation_source: boundary_supplied`). A + record without it, or with another value, is another execution's and answers + for nothing here — the first record is held to the token exactly as every later + one is. The boundary holds one connection per grant, so a run that asks about + several kinds of effect spans several connections; the correlation is one value + for the whole run and does not, which is why the run is matched by it and not by + a connection. Under `--ungoverned` the program runs with nothing in front of it + and the chain alone answers, so the records were written by another execution by + construction and no correlation is required of them. + +**What matching does not establish, stated rather than implied.** It does not +compare the arguments of the observed call with the digest the record carries. +Two effects of one kind, by one principal, in one run are therefore not told +apart: a record this run produced can, in principle, be a decision that covered a +different call of the same kind. Which arguments identify an +effect is the pack's declaration and the boundary's digest (article 11), and a +pack publishes no way to render them for anything but its own wrapper — so a +verifier that computed a digest of its own would be a second policy, agreeing +with the first on the day it was written and drifting afterwards, and its +disagreements would arrive as findings nobody made. Closing this needs the pack +interface to publish the rendering, which is a change to what a pack is; until +it does, the limit is here in writing rather than hidden behind a green run. + +## Coverage + +A verdict is about the points a pack names. **Coverage** is about everything +else of that kind: whether the effects this run judged were all the effects of +that kind the run made. + +Instrumentation can miss a call, and that limit is real, documented and +unchanged — a governed program *calls* the boundary, and a call that reaches +the world by some other route was never asked about (article 2). What changed +is the silence. An effect that took such a route used to be dropped, and the +point's verdict — earned by the calls that *did* go through the interposed +attribute — was then published as though it were the whole of the run: two +processes created, one decision, `governed`, exit `0`. + +So a point may name, in its own `pack.toml`, the audit events by which an +effect of its kind reaches the world along a path **it does not interpose**: + + uninterposed_events = ["os.posix_spawn", "os.system"] + +The declaration is the pack's because a pack is the one place a library's +vocabulary may be written down (article 4); the verifier holds none of its own +and watches for exactly what it was told. When such an event arrives while the +program is running, the run counts it on every point of that pack as an effect +it could not judge, says so on the error stream with the reason, and **lets the +call through**. A run holding one of those counts cannot answer `0`: it exits +`7`, the code for a check that could not conclude. + +Letting it through is the point. `sayfirst instrument run` does not interpose +that path either, so a `verify` that aborted the call would be stricter than the +mode it exists to measure, and this software is a governance and observability +layer rather than a confinement mechanism. The honest report is « an effect of +this kind reached the world and this run could not judge it », not « this run +stopped it ». + +**An interposed call can raise one of those events itself.** `subprocess.Popen` +creates the process it was asked for through `os.posix_spawn` whenever it can — +for an executable named with a directory, by default from Python 3.14 — and +otherwise through `_posixsubprocess.fork_exec`, which raises an event of its +own from Python 3.14. Both are paths the subprocess pack names, so one governed +spawn used to be reported as one judged effect plus one effect nobody judged, +and the run exited `7` for a program that did nothing around the pack. A point +therefore names, among its uninterposed events, the ones its own call raises: + + inner_events = ["os.posix_spawn", "_posixsubprocess.fork_exec"] + +Such an event is not counted when it is **the very next event the same thread +raises** after the call the point judged and let through, and it carries as its +second argument the argument vector that call's event carried as its second — +the process `Popen` was asked for, against the process created beneath it. +Anything else is counted: another command, a second inner event, an inner event +raised after anything else the thread did. An inner event must be one the same +point names as uninterposed, and a pack declaring otherwise is refused. + +One shape stays out of reach, and it is written here rather than hidden. Before +Python 3.14, `Popen` creates a process it cannot hand to `os.posix_spawn` with +no event at all; a program whose very next act after such a call is a direct +`os.posix_spawn` of **the same command** has that second process paired with +the first. It names the command the plane just allowed; it is still a second +process. On 3.14 the fork-and-exec raises its own event, which takes the pairing, +and the direct spawn is counted. That same event is also how `multiprocessing` +starts a process — by default on Linux from 3.14 — so the subprocess pack names +it as a path it does not interpose, and a process started that way is counted. +Before 3.14 it raises nothing and is not. + +Two other things leave coverage incomplete, and both are counted the same way +and for the same reason. + +* **A run that forked.** A fork copies the audit hook, the chain's position and + every finding into a memory the process that writes the report cannot read. + The child goes on being watched — it aborts an effect no record covers, + exactly as the parent would — and then its observations end with it. + Collecting them from the parent's side is not something this harness can do; + saying that they are missing is, and a report that does not cover the whole + run is not a pass. A child never writes the run's report or its outcome file: + it holds the parent's paths, and a copy writing them would replace the + parent's findings with its own view of a run the parent is still concluding. +* **A walk of the chain that did not reach the end.** `read_something` says a + read was answered; it does not say the chain was read whole. With a + continuation this client could not follow, a matching record on the page it + never read is indistinguishable from no record at all — so the effect is + aborted and counted, and no finding is published. `ungoverned` is a finding + this client made, and incomplete information cannot support one. + +Each of these is a count and a sentence, never a fourth verdict: every way of +spelling « could not judge » as one of the three words says something the run +did not measure, and the count is what refuses the pass. + +## Why not `requests`, `httpx`, `os.system` or a Postgres driver + +These are the libraries a convenience pack is most often wanted for, and they are +deliberately not shipped, for a reason in the verifier rather than in the +classification. The verifier confirms an effect from a **distinct CPython audit +event** and shares no state with the engine — a proof that trusts the thing it +proves is not a proof (§ Verifying). The shipped packs each key on such an event: +`subprocess.Popen`, `urllib.Request`, `sqlite3.connect`. `requests` +and `httpx` emit none of their own: their traffic reaches the interpreter only as +`socket.connect`, which every other socket also raises, so a pack keying on it +would claim urllib's connections and a second network pack's alike. A Postgres +driver is the same — `socket.connect`, plus a live server to exercise it. + +`os.system` fails a different, equally real guard. It is a distinct audit +event (`os.system`), so the verifier could confirm it — but its MODULE is +`os`, which the engine itself imports and uses throughout. The rule that the +engine names no shipped pack's library (§ below; enforced by +`tests/test_engine_is_agnostic.py`, which walks the engine's own source) then +reads every `import os` in the engine as a special case for this pack's +library, and rightly so: a real special case for `os` would be invisible +beside them. Exempting `os` to ship the pack would blind that guard to the +most-used module in the engine, which is a security guard weakened to force a +convenience. So `os.system` stays a path the `subprocess` pack NAMES as one it +does not interpose and counts (§ Coverage), not a pack of its own. + +Such a library can be **governed** (a pack wrapping `requests.Session.request` +asks before the call, and `instrument run` would honour it); it cannot be +**verified**, and article 9's gate requires the verifier to inspect every shipped +pack. Shipping one anyway would either fail that gate or make the verifier key on +a shared event — weakening the one-event-per-effect rule the whole proof rests +on. Closing this needs a verifier that can be given a library-specific +confirmation without trusting the engine for it, which is a change to what the +verifier is; until then, these are governed by writing the wrapper by hand +against `sayfirst-boundary`, not by a shipped pack. + ## No registry, no marketplace, no signature scheme There is no registry, no marketplace and no signature scheme. This statement diff --git a/docs/PARTITION.md b/docs/PARTITION.md index bfccc8f..0001ec7 100644 --- a/docs/PARTITION.md +++ b/docs/PARTITION.md @@ -129,15 +129,17 @@ move from **plane** to this repository, and the table above now says so with the article that carries each. The control plane's operator surface keeps what inspects its own daemon — `status`, `doctor`, `policy show|history`, `plugins list` — and `systems {register,retire}` stays with it under its own -question. The decision narrows question 2 rather than overturning it: what +question. (What it implements today is `sayfirstd status`, `whoami`, +`plugins list` and `conformance replay`; the decision placed the others, it +did not ship them.) The decision narrows question 2 rather than overturning it: what travels with the daemon is what reads the daemon's *own state*, and evidence outlives the process that wrote it. **The consequence, stated so that it cannot be read as costless.** This repository now **owes an evidence surface**. Three command families are named -here and none of them exists: the `here` column is a debt, not an inventory, -and until a slice lands them the table describes a repository that answers one -command. The README's promise stops being an aspiration and becomes a +here and, when this was decided, none of them existed: the `here` column was a +debt, not an inventory. (All three shipped on 2026-09-15; the table above marks +them running.) The README's promise stops being an aspiration and becomes a commitment with a date on it — a sentence that can now be tested against this repository rather than argued about. It also puts an article 13 obligation on this repository specifically: verifying an export must be possible with the @@ -177,10 +179,9 @@ trees and not about any installed environment — but two wheels that both ship belong to the product command-line interface, which is this repository. The control plane's repository keeps its operator surface and renames it to the daemon's own form of the product name — the conventional Unix shape, where the -daemon and the commands that inspect it share one binary — and that change is -on a branch of that repository, dated 2026-09-05 and not merged. Until it -merges, both trees still declare the three names and only this repository's -claim on them is the settled one. This document's earlier position was an **assumption**, stated so +daemon and the commands that inspect it share one binary. That change merged the +same day (the control plane's operator command is `sayfirstd`), and only this +repository declares the three names. This document's earlier position was an **assumption**, stated so it would be visible; it is now the decision, and it changes nothing in this tree because the assumption and the decision agree. diff --git a/pyproject.toml b/pyproject.toml index 98f0784..20a7040 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 [project] name = "sayfirst-cli" -version = "0.2.0" +version = "0.3.0" description = "The product command-line interface of the sayfirst control plane" readme = "README.md" requires-python = ">=3.12,<3.15" @@ -20,11 +20,11 @@ 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.2.0", "sayfirst-boundary==0.2.0"] +dependencies = ["sayfirst-contract==0.3.0", "sayfirst-boundary==0.3.0"] -# The public repository this distribution sends a reader to. It does not exist -# yet: the repository is created fresh in the act that publishes it, and these -# are the names the operator chose for that act. The guard +# 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 +# that act. The guard # `tests/test_pointers_survive_publication.py` holds them, so a rename lands # here and there together or not at all. No licence classifier: the licence is # declared as an expression above, and an index refuses a distribution that @@ -41,7 +41,7 @@ sayfirst = "sayfirst_cli.main:run" dev = [ "pytest==8.4.1", "ruff==0.12.12", - "sayfirst-contract[stub]==0.2.0", + "sayfirst-contract[stub]==0.3.0", ] [build-system] @@ -58,7 +58,7 @@ packages = ["src/sayfirst_cli"] # environment built from the actual wheel and fails when one is missing. (The # tests cannot prove this: the gate installs this project editable, so every # reader they exercise resolves to the source tree.) -include = ["src/sayfirst_cli/packs/**"] +include = ["src/sayfirst_cli/packs/**", "src/sayfirst_cli/instrument/_bootstrap/**"] [tool.hatch.build.targets.sdist.force-include] "LICENSE" = "LICENSE" diff --git a/scripts/gate.sh b/scripts/gate.sh index f369c25..1e36042 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -12,19 +12,21 @@ # install, and proving on every run that it # can fail # -# `sayfirst-contract` is published on no index — article 0 forbids publishing -# anything until the marks are filed — so this gate is told where a checkout of -# the control plane repository is, and it builds the contract from a named ref -# of it rather than from whatever that clone has checked out. +# The contract this client pins is built from source, never taken from an index: +# the version a change pins may not be on one yet (it is published with the +# release that pins it), and a gate that read the index would prove this client +# against whatever the index held. So this gate is told where a checkout of the +# control plane repository is, and it builds the contract from a named ref of +# it rather than from whatever that clone has checked out. # # SAYFIRST_CONTRACT_SOURCE default ../sf-control-plane-lt # SAYFIRST_CONTRACT_REF default origin/main # SAYFIRST_PYTHON default 3.13 # -# The contract is on no index and cannot be vendored here either (article 14 and -# `docs/PROVENANCE.md`), so on some machines — a fork with no network, a runner -# behind a proxy, anyone offline — it is simply not there. This gate does -# not treat that as a crash, and does not treat it as a pass. It has three +# The contract is built from that checkout and cannot be vendored here either +# (article 14 and `docs/PROVENANCE.md`), so on some machines — a fork with no +# network, a runner behind a proxy, anyone offline — it is simply not there. +# This gate does not treat that as a crash, and does not treat it as a pass. It has three # outcomes rather than two, which is article 2's rule about status surfaces # applied to the gate's own status: # @@ -96,7 +98,7 @@ else # is measured below rather than assumed. venv="$repository/.venv-reduced" echo "gate: no checkout of the control plane repository at $contract_source" - echo "gate: the contract is on no index (article 0) and is not vendored here" + echo "gate: the contract is built from that source and is not vendored here" echo "gate: (article 14), so this run is reduced. Set SAYFIRST_CONTRACT_SOURCE" echo "gate: to a checkout of that repository for the full gate." fi diff --git a/src/sayfirst_cli/approvals.py b/src/sayfirst_cli/approvals.py index d6dd3dd..8559d93 100644 --- a/src/sayfirst_cli/approvals.py +++ b/src/sayfirst_cli/approvals.py @@ -7,14 +7,13 @@ answer the same document a `show` would: the record the act left behind, so a caller renders one writer whichever of the three it asked for. -Unlike `read_decision`, the daemon does NOT close the connection after -`read_approval` or `resolve_approval` (the transport's own docstrings say so): -it writes both answers through its own handler and leaves the connection for -the next request. That is what lets a person read a wait and then end it -without reconnecting — and it is the daemon's behaviour to rely on, not a -promise the daemon owes across every future release. One command here ever -does one thing, so nothing in this module reads twice on the same connection -and rule C4's explicit reconnect never comes up. +The daemon leaves the connection open after `read_approval` and +`resolve_approval`, as it does after every document it answers; only a stream +ends its connection. That is what lets a person read a wait and then end it +without reconnecting — and it is the daemon's behaviour, not a promise the +daemon owes across every future release. One command here ever does one +thing, so nothing in this module reads twice on the same connection and rule +C4's explicit reconnect never comes up. """ from __future__ import annotations @@ -40,7 +39,12 @@ def _stated(value: object) -> str: def _write_approval(document: Mapping[str, object], stream: TextIO) -> None: - """The seven members a person reads about one wait — read or resolved alike.""" + """The members a person reads about one wait — read or resolved alike. + + `person` only where the record has one: the contract renders it only then + (article 12 names the person; a wait nobody acted on has none), so its + absence is not « not stated » but no line at all. + """ stream.write(f"approval: {document['approval_ref']}\n") stream.write(f"decision: {document['decision_ref']}\n") stream.write(f"state: {document['state']}\n") @@ -48,6 +52,9 @@ def _write_approval(document: Mapping[str, object], stream: TextIO) -> None: stream.write(f"deadline: {document['deadline']}\n") stream.write(f"resolved_at: {_stated(document.get('resolved_at'))}\n") stream.write(f"reason: {_stated(document.get('resolution_reason'))}\n") + person = document.get("person") + if isinstance(person, str): + stream.write(f"person: {person}\n") def _connection_parser(prog: str) -> argparse.ArgumentParser: diff --git a/src/sayfirst_cli/ask.py b/src/sayfirst_cli/ask.py index 1c5c188..cf6e63d 100644 --- a/src/sayfirst_cli/ask.py +++ b/src/sayfirst_cli/ask.py @@ -24,6 +24,7 @@ from sayfirst_contract.client import Answered, Refused from sayfirst_contract.decisions import DecisionAsk, Outcome from sayfirst_contract.generation import CONTRACT_GENERATION +from sayfirst_contract.problems import REFUSED from sayfirst_contract.transport.socket_client import ( PER_USER, SYSTEM, @@ -54,7 +55,7 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--capability", required=True, help="the kind of effect, and nothing more") parser.add_argument("--scope", default="local", help="the scope the question is asked in") - parser.add_argument("--socket", required=True, help="the path of the daemon's socket") + reads.add_socket_argument(parser) parser.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER) parser.add_argument( "--daemon-user", @@ -86,8 +87,9 @@ def main( arguments = build_parser().parse_args(argv) try: + address = reads.address_of(arguments) profile = SocketProfile( - arguments.socket, + address.path, mode=arguments.mode, daemon_user=arguments.daemon_user, scope=arguments.scope, @@ -101,9 +103,10 @@ def main( declared = declared_delegation() try: - connection = connect(profile) + connection = connect(profile, timeout=reads.READ_TIMEOUT) except SocketClientProblem as failure: - could_not_ask = failure.classification != "refused" + reads.say_where_it_looked(address, arguments, stderr) + could_not_ask = failure.classification != REFUSED document = render.envelope( CONTRACT_GENERATION, render.verification_document(None, None, False), @@ -133,11 +136,17 @@ def main( # for `deny` (articles 1 and 2). The one rule lives beside the reads. result = reads.read(lambda: connection.ask_decision(ask, declared)) if isinstance(result, Answered): - document = render.envelope( - result.contract_generation, verification, result=result.value.to_document() - ) - _write(document, arguments.json, stdout) - return _exit_for_answer(result.value.outcome) + # Bounded as every read is: a decision nested past what this client + # reads is an answer it could not read, whatever its outcome says — + # never a traceback, whose status 1 is this client's « deny ». + rendered = reads.read(lambda: reads.bounded(result)) + if isinstance(rendered, Answered): + document = render.envelope( + result.contract_generation, verification, result=rendered.value + ) + _write(document, arguments.json, stdout) + return _exit_for_answer(result.value.outcome) + result = rendered could_not_ask = not isinstance(result, Refused) document = render.envelope( CONTRACT_GENERATION, diff --git a/src/sayfirst_cli/evidence.py b/src/sayfirst_cli/evidence.py index b775336..29ed78b 100644 --- a/src/sayfirst_cli/evidence.py +++ b/src/sayfirst_cli/evidence.py @@ -4,7 +4,9 @@ from __future__ import annotations import argparse +import errno import json +import stat import sys from collections.abc import Callable, Mapping, Sequence from dataclasses import replace @@ -13,6 +15,7 @@ from sayfirst_contract.client import Answered, CouldNotAsk, Refused, Result from sayfirst_contract.evidence import ( + GRADES, ChainCondition, ChainVerdict, ExportVerdict, @@ -92,9 +95,12 @@ def _history(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int: def _audit(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int: parser = argparse.ArgumentParser(prog="sayfirst evidence audit") - source = parser.add_mutually_exclusive_group(required=True) + # One or the other and never both; neither is an online audit at the + # per-user default address, which is what every other read does with no + # `--socket` (`reads.address_of`). + source = parser.add_mutually_exclusive_group() source.add_argument("--file", type=Path, help="check an export without opening a socket") - source.add_argument("--socket", help="the path of the daemon's socket") + source.add_argument("--socket", default=None, help=reads.SOCKET_HELP) parser.add_argument("--scope", help="the scope the question is asked in") parser.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER) parser.add_argument("--daemon-user", default=None) @@ -106,7 +112,7 @@ def _audit(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int: if arguments.scope is not None: parser.error("--file is not allowed with --scope") if arguments.from_sequence is not None or arguments.to_sequence is not None: - parser.error("--from and --to require --socket") + parser.error("--from and --to bound an online audit and are not allowed with --file") return _offline(arguments, out, err) if arguments.scope is None or arguments.from_sequence is None: parser.error("online audit requires --scope and --from") @@ -116,6 +122,30 @@ def _audit(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int: return _online(arguments, out, err, history=False) +#: The errors `pathlib` read as « not there » before Python 3.14: a missing +#: entry, a path through something that is not a directory, a bad descriptor +#: and a symlink loop. From 3.14 its predicates answer « no » for every error. +_NOT_THERE: Final[frozenset[int]] = frozenset( + {errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP} +) + + +def _entry_mode(path: Path, *, follow: bool) -> int | None: + """The mode of the entry at `path`, `None` when there is none; any other failure raises. + + Asked of `os` rather than of `Path.exists` or `Path.is_file`, which stopped + raising in Python 3.14: there a directory this process may not search + answers « no such file », and a name too long answers « free to use » — + two things this client does not know, reported as two it does. + """ + try: + return (path.stat() if follow else path.lstat()).st_mode + except OSError as failure: + if failure.errno in _NOT_THERE: + return None + raise + + def _export(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int: parser = argparse.ArgumentParser(prog="sayfirst evidence export") reads.add_connection_arguments(parser) @@ -129,7 +159,7 @@ def _export(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int: # Reject every existing directory entry, including a dangling symlink, # before verifying or reading from the daemon. try: - taken = path.exists() or path.is_symlink() + taken = _entry_mode(path, follow=False) is not None except OSError as failure: # A path this process cannot even look at (too long, in a directory # it may not search) is unusable, and nothing has been asked yet. @@ -153,7 +183,15 @@ def _export(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int: ) if isinstance(result, Answered): bundle = result.value - contents = json.dumps(bundle, sort_keys=True, indent=2) + "\n" + try: + contents = json.dumps(bundle, sort_keys=True, indent=2) + "\n" + except (RecursionError, ValueError, TypeError) as failure: + # The bundle is saved unbounded, so this is the step a bundle + # nested past what the encoder can write fails in. Nothing is + # saved and nothing is checked: « could not check », with the + # reason — never a traceback, whose status 1 is « deny ». + err.write(f"could not save: {arguments.out}: {failure}\n") + return exit_codes.EXIT_COULD_NOT_CHECK try: # Exclusive creation also refuses a path created while the # read was in flight; the early check alone cannot do that. @@ -234,7 +272,7 @@ def _exports(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int: return _could_not_check(arguments.directory, failure, arguments.json, err) for path in paths: try: - regular = path.is_file() + mode = _entry_mode(path, follow=True) except OSError as failure: # The listing was readable but the entry is not even stat-able # (a directory without search permission): not a bundle is not @@ -247,7 +285,7 @@ def _exports(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int: reason=_could_not_check_entry(path, failure, arguments, err, codes), ) continue - if not regular: + if mode is None or not stat.S_ISREG(mode): # A directory or a dangling link named like a bundle is listed, # never dropped: silence here would read as « no such file ». _list_entry(bundles, out, path.name, as_json=arguments.json, reason=None) @@ -328,6 +366,24 @@ def _exports(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int: return 0 +def _grade_strength(grade: object) -> int: + """How strong a grade is, on the contract's own published order. + + `GRADES` is the contract's vocabulary and it is published weakest first, so + the ordering the weakest-grade rule needs is read from the party that owns + it. A private list here would be a second copy of the contract's semantics + inside the client, free to drift from it — and drift between this client + and the contract about one range is exactly the defect this comparison + exists to close. + + A value this generation does not define ranks below every value it does: an + unknown grade is not a stronger one, so it is never outranked by a known + grade and never renders as one. That is article 13's « read an unknown as + unknown » in the direction a weakest-grade rule has to fail. + """ + return GRADES.index(grade) if grade in GRADES else -1 + + def _merged_verdict(verdicts: Sequence[Mapping[str, object]]) -> dict[str, object]: """One served verdict for a read that spanned several pages, dropping nothing. @@ -337,11 +393,19 @@ def _merged_verdict(verdicts: Sequence[Mapping[str, object]]) -> dict[str, objec on every earlier one, and a declared drop rendered as a clean chain is an absence rendered as a healthy state, which article 2 forbids. - So: declared gaps are concatenated in page order; a grade is kept per - connection, a later page's replacing an earlier one; and the condition is - `intact` only if every page said so. The first page that said otherwise is - the one whose verdict is reported, its `sequence`, `expected` and `found` - included, so that what is rendered beside the condition belongs to it. + So: declared gaps are concatenated in page order; a connection is graded by + the WEAKEST grade any page gave it, because article 7 makes the verdict's + grade « the weakest grade in effect over the period covered » and the period + a merged verdict covers is every page's; and the condition is `intact` only + if every page said so. The first page that said otherwise is the one whose + verdict is reported, its `sequence`, `expected` and `found` included, so + that what is rendered beside the condition belongs to it. + + Keeping the LAST page's grade was this merge's own rule, not the plane's: + a connection the daemon graded `unverified` on page 1 and `observability` + on page 2 came out `observability`, so the client rendered a stronger claim + over the range than the contract's own verifier computes over the same + range — the two disagreed about one period, and the client was wrong. Every member of what comes back is a member the plane's own verifications carry. How many pages the answer is made of is this client's fact, not the @@ -353,11 +417,13 @@ def _merged_verdict(verdicts: Sequence[Mapping[str, object]]) -> dict[str, objec verdicts[-1], ) gaps: list[object] = [] - grades: dict[object, object] = {} + grades: dict[object, Mapping[str, object]] = {} for verdict in verdicts: gaps.extend(verdict["declared_gaps"]) for grade in verdict["grades"]: - grades[grade["connection_id"]] = grade + held = grades.get(grade["connection_id"]) + if held is None or _grade_strength(grade["grade"]) < _grade_strength(held["grade"]): + grades[grade["connection_id"]] = grade merged: dict[str, object] = {**base, "declared_gaps": gaps, "grades": list(grades.values())} # The range members describe the whole read, not the page the condition # came from: a gap declared at 2 inside a verdict claiming « from 3 » would diff --git a/src/sayfirst_cli/exit_codes.py b/src/sayfirst_cli/exit_codes.py index 40d85a7..024df37 100644 --- a/src/sayfirst_cli/exit_codes.py +++ b/src/sayfirst_cli/exit_codes.py @@ -8,7 +8,7 @@ shell that collapses them into "non-zero" has lost the one distinction the constitution insists on. -Three of these codes are not this repository's to choose. `sayfirst whoami`, +Three of these codes are not this repository's to choose. `sayfirstd whoami`, which the contract distribution implements, already publishes `0`, `3`, `4` and `64` for the same four situations; a second client of the same contract that numbered them differently would make the same event read two ways depending on @@ -36,12 +36,12 @@ EXIT_DENY: Final[int] = 1 #: The request was refused — the question was received and rejected. -#: The value `sayfirst whoami` publishes for the same situation. +#: The value `sayfirstd whoami` publishes for the same situation. EXIT_REFUSED: Final[int] = 3 #: The control plane could not be asked, or answered something this generation #: cannot read. Never rendered as a denial (articles 1 and 2). The value -#: `sayfirst whoami` publishes for the same situation. +#: `sayfirstd whoami` publishes for the same situation. EXIT_COULD_NOT_ASK: Final[int] = 4 #: The control plane answered `suspend`: the effect waits for a person. @@ -60,7 +60,7 @@ #: The invocation was wrong, or something it named cannot be read — a profile #: that cannot say what it must verify, a pack manifest that does not parse, a -#: pack this distribution ships that will not read. The value `sayfirst whoami` +#: pack this distribution ships that will not read. The value `sayfirstd whoami` #: publishes for the same situation; `packs list` uses it for a broken shipped #: pack rather than minting a code for a case no caller can act on differently, #: because a distinct number would cost one in a table three commands share and diff --git a/src/sayfirst_cli/instrument/_bootstrap/sitecustomize.py b/src/sayfirst_cli/instrument/_bootstrap/sitecustomize.py new file mode 100644 index 0000000..c636c72 --- /dev/null +++ b/src/sayfirst_cli/instrument/_bootstrap/sitecustomize.py @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: Apache-2.0 +"""What a followed child runs before its own program: it installs the boundary, or dies. + +This file is imported automatically by any Python whose import path has this +directory at its head — which `follow.environment_for` arranges for the children +of a `sayfirst instrument run --follow-children` (see `follow.py`). It reads the +run's configuration from the environment, installs the same packs against the +same daemon, and then hands control on to whatever `sitecustomize` was already +there. With no configuration it does nothing, so this directory sitting on a +path harms no unrelated interpreter. + +**It fails closed.** Anything that stops the boundary going in — the packs, the +profile, this client not importable here — is raised, and the child interpreter +exits before the program runs. A followed child governs its effects or it does +not run. The engine is left installed for the life of the child, and the +environment is left as it was, so a grandchild is followed too. +""" + +from __future__ import annotations + +import os + + +def _install_the_boundary() -> None: + """Install the run's packs against its daemon, in this child interpreter.""" + import json + from pathlib import Path + + # Imported here, not at module top: this file is imported by EVERY child of a + # followed run, and one that carries no configuration must cost nothing and + # must not need this client importable at all. + from sayfirst_boundary import Boundary + from sayfirst_contract.binding.http_unix_socket.client import SocketClient + from sayfirst_contract.transport.socket_client import ( + SocketProfile, + expected_principal_uid, + ) + + from sayfirst_cli.instrument import follow, launch, manifest + from sayfirst_cli.instrument.engine import Engine + + configured = json.loads(os.environ[follow.CONFIG_VARIABLE]) + packs = [manifest.read_pack(Path(directory)) for directory in configured["packs"]] + profile = SocketProfile( + configured["socket"], + mode=configured["mode"], + daemon_user=configured["daemon_user"], + scope=configured["scope"], + ) + client = SocketClient( + socket_path=Path(profile.socket_path), + expected_uid=expected_principal_uid(profile), + timeout=launch.ASK_TIMEOUT, + ) + boundary = Boundary( + client=client, + principal_reference=configured["principal"] or launch.this_account(), + ) + Engine().install(packs, boundary, scope=profile.scope) + + +def _hand_on_to_any_prior_sitecustomize() -> None: + """Run a `sitecustomize` that was on the path behind this one, if there is one. + + Following children must not silently disable a site's own start-up file. + Rather than scan for a literal `sitecustomize.py` — which misses a + `sitecustomize` PACKAGE and loads a source file under a private name that + breaks any code looking itself up in `sys.modules` (a dataclass with + postponed annotations, for one) — this hands off through Python's OWN import + resolution: it removes this bootstrap directory from the import path and this + module from `sys.modules`, then imports `sitecustomize` again. Whatever + Python would have found had this directory not been first — a module or a + package — is found and registered exactly as start-up would have registered + it. The bootstrap directory stays on the child's `PYTHONPATH` for its own + children (that is the environment, not this process's `sys.path`), so a + grandchild is still followed. + """ + import importlib + import sys + + here = os.path.realpath(os.path.dirname(__file__)) + kept = [entry for entry in sys.path if os.path.realpath(entry) != here] + if len(kept) == len(sys.path): + return # this bootstrap was not reached through a directory we can drop + saved_path, sys.path[:] = list(sys.path), kept + saved_module = sys.modules.pop("sitecustomize", None) + try: + importlib.import_module("sitecustomize") + except ImportError: + # No other `sitecustomize` on the path: there was nothing to chain to. + if saved_module is not None: + sys.modules.setdefault("sitecustomize", saved_module) + finally: + sys.path[:] = saved_path + + +if os.environ.get("SAYFIRST_FOLLOW_CHILDREN"): + try: + _install_the_boundary() + except BaseException as failure: # a followed child fails closed on any setup error + raise SystemExit( + "sayfirst: this child of a --follow-children run could not install the " + f"boundary, so it will not run ungoverned: {failure}" + ) from failure + +_hand_on_to_any_prior_sitecustomize() diff --git a/src/sayfirst_cli/instrument/commands.py b/src/sayfirst_cli/instrument/commands.py index b2db3da..3787761 100644 --- a/src/sayfirst_cli/instrument/commands.py +++ b/src/sayfirst_cli/instrument/commands.py @@ -19,23 +19,26 @@ from __future__ import annotations import argparse +import os import sys from collections.abc import Sequence from pathlib import Path from typing import Final, TextIO +from sayfirst_boundary import AskRefused, BoundaryError, CouldNotAsk, Denied, Suspended from sayfirst_contract.client import Refused from sayfirst_contract.generation import CONTRACT_GENERATION from sayfirst_contract.transport.socket_client import ( PER_USER, SYSTEM, + ProfileAddress, ProfileMisuse, SocketClientProblem, SocketProfile, ) from .. import exit_codes, reads, render -from . import engine, launch, manifest, verify +from . import designation, engine, interpreter, launch, manifest, verify #: What `apply` says instead of doing anything. The sentence names the mode that #: does exist, because a refusal that leaves the reader with no next step is @@ -66,11 +69,11 @@ def build_parser() -> argparse.ArgumentParser: "--pack", action="append", required=True, - metavar="DIR", - help="a pack directory; repeat the option for each pack", + metavar="PACK", + help=designation.HELP, ) running.add_argument("--scope", required=True, help="the scope the questions are asked in") - running.add_argument("--socket", required=True, help="the path of the daemon's socket") + reads.add_socket_argument(running) running.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER) running.add_argument( "--daemon-user", @@ -83,11 +86,22 @@ def build_parser() -> argparse.ArgumentParser: metavar="REF", help="the principal reference to hold grants against; this account by default", ) + running.add_argument( + "--follow-children", + action="store_true", + help=( + "install the boundary in the Python children the program spawns with its " + "environment, so their effects are governed as well; a child started with -S, -I " + "or -E, or with a PYTHONPATH of its own, is not followed; needs this client " + "installed in the interpreter the children run, and fails a child closed if it " + "cannot" + ), + ) running.add_argument( "target", nargs="*", metavar="TARGET", - help="after `--`: either -m MODULE [args] or SCRIPT [args]", + help=interpreter.TARGET_HELP, ) verify.add_arguments( verbs.add_parser("verify", help="prove every named effect of a program was decided") @@ -109,7 +123,81 @@ def main( if forwarded and forwarded[0] in RESERVED: stderr.write(f"{RESERVED[forwarded[0]]}\n") return exit_codes.EXIT_MISUSE - return _run(build_parser().parse_args(forwarded), stdout, stderr) + parser = build_parser() + arguments = parser.parse_args(forwarded) + try: + _to_the_interpreter_the_target_names(parser, arguments) + except launch.LaunchMisuse as misuse: + # A word spelled like an interpreter that names none this client can run + # in. Nothing was handed over and no program started, so it is the + # invocation's mistake, like every other refusal before the hand-off. + stderr.write(f"{misuse}\n") + return exit_codes.EXIT_MISUSE + return _run(arguments, stdout, stderr) + + +def _to_the_interpreter_the_target_names( + parser: argparse.ArgumentParser, arguments: argparse.Namespace +) -> None: + """Hand the whole command to the interpreter a target names, if it names another. + + `interpreter.py` says why. A target that names no interpreter is left + alone; one that names the interpreter already running has the word taken + off and goes on here; one that names another does not come back — this + process becomes that interpreter running this same command. + + The command line handed over is respelled from what the parser READ, never + cut out of what was typed: where the target begins in the raw words depends + on how options and `--` were interleaved, and a rule about that written a + second time here would be a second parser. Every option the verb's own + parser declares is carried by walking that parser, so an option added to it + later cannot be the one this forgot. + """ + if os.environ.pop(interpreter.CHOSEN_VARIABLE, None): + # This `sayfirst instrument` is the one a hand-off ran inside the + # interpreter it chose: the target has been resolved once already and + # runs here, in this process, without being read a second time. Reading + # it again would exec away from the interpreter just chosen (a target + # whose shebang names another) or loop forever (an interpreter that + # resolves to a shim whose path never equals this one). Removed as it is + # read, so a program that itself runs `sayfirst instrument` starts clean. + return + chosen = interpreter.selection(arguments.target) + if chosen is None: + return + executable, program = chosen + if interpreter.is_this_one(executable): + # The interpreter the target names is the one already running: the word + # (or nothing, for an executable whose shebang names this Python) is + # taken off and the program runs here, exactly as it would have. + arguments.target = program + return + interpreter.hand_the_command_to( + executable, + ["instrument", arguments.verb, *_respelled(parser, arguments), "--", *program], + follow_children=getattr(arguments, "follow_children", False), + ) + + +def _respelled(parser: argparse.ArgumentParser, arguments: argparse.Namespace) -> list[str]: + """Every option of this verb, as the words that would be read back as the same values.""" + verbs = next( + action for action in parser._actions if isinstance(action, argparse._SubParsersAction) + ) + words: list[str] = [] + for action in verbs.choices[arguments.verb]._actions: + if not action.option_strings or isinstance(action, argparse._HelpAction): + continue + value = getattr(arguments, action.dest) + option = action.option_strings[-1] + if isinstance(value, bool): + words.extend([option] if value else []) + elif isinstance(value, list): + for item in value: + words.extend([option, str(item)]) + elif value is not None: + words.extend([option, str(value)]) + return words def _run(arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO) -> int: @@ -129,16 +217,17 @@ def _run(arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO) -> int: packs: list[manifest.Pack] = [] for named in arguments.pack: try: - packs.append(manifest.read_pack(Path(named))) + packs.append(designation.read(named)) except manifest.PackInvalid as invalid: - # The path as it was TYPED, not as it was resolved: with more than + # The designation as it was TYPED, not as it was resolved: with more than # one `--pack` the sentence alone does not say which one to fix, and # a resolved path is not what the reader has in their shell history. stderr.write(f"{named}: {invalid}\n") return exit_codes.EXIT_MISUSE try: + address = reads.address_of(arguments) profile = SocketProfile( - arguments.socket, + address.path, mode=arguments.mode, daemon_user=arguments.daemon_user, scope=arguments.scope, @@ -146,12 +235,14 @@ def _run(arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO) -> int: except ProfileMisuse as invalid: stderr.write(f"{invalid}\n") return exit_codes.EXIT_MISUSE + _say_an_empty_default(address, stderr) try: return launch.run( packs, profile, arguments.target, principal=arguments.principal, + follow_children=getattr(arguments, "follow_children", False), out=stdout, err=stderr, ) @@ -177,8 +268,62 @@ def _run(arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO) -> int: # Raised while the connection was being arranged, so no question was # ever put. It is reported with the contract's own classification and # never as a denial (articles 1 and 2); a refusal the boundary raises - # later, inside the program, is the program's and is not caught here. + # later, inside the program, is the program's and is handled below. return _write_problem(failure, stderr) + except BoundaryError as outcome: + # An outcome the boundary raised into the program, which did not handle + # it. It ends the way the interpreter ends any program on an exception — + # its traceback, through the program's own hook — and then with the + # status this client publishes for that outcome. The interpreter's own + # status for an uncaught exception is 1, this client's « deny », so a + # shell could not tell a denial from a control plane that could not be + # asked (article 1). A program that handles the outcome, or raises + # something of its own from it, owns its ending and is not read here. + sys.excepthook(type(outcome), outcome, outcome.__traceback__) + code = ending_for(outcome) + return exit_codes.EXIT_COULD_NOT_ASK if code is None else code + + +#: The status for each outcome the boundary can raise into a program, the same +#: numbers `ask` returns for the same answers. +_ENDINGS: Final[tuple[tuple[type[BoundaryError], int], ...]] = ( + (Denied, exit_codes.EXIT_DENY), + (Suspended, exit_codes.EXIT_SUSPEND), + (AskRefused, exit_codes.EXIT_REFUSED), + (CouldNotAsk, exit_codes.EXIT_COULD_NOT_ASK), +) + + +def ending_for(outcome: BaseException) -> int | None: + """The published status for a boundary outcome, or `None` for anything else.""" + for kind, code in _ENDINGS: + if isinstance(outcome, kind): + return code + return None + + +def _say_an_empty_default(address: ProfileAddress, stderr: TextIO) -> None: + """Name an address nobody typed when nothing is there, before the program starts. + + The connection is arranged when the program's first named effect asks, so a + daemon that is not running is found out inside the program, as the + boundary's own exception, which names no path. With `--socket` the reader + typed the path; with the default they never saw it, and « could not ask » + at a name nobody showed them is a failure they cannot act on (article 2). + + It changes nothing about what runs. A program that asks nothing still runs + to its own ending, an effect a pack names still fails closed when it is + reached, and a daemon that starts in between is simply asked — this is one + line on the error stream, written only when it is true at the moment it is + written, and it is the launcher's to write because the program has not + started yet (`launch.py` draws that line). + """ + if address.defaulted and not Path(address.path).exists(): + stderr.write( + f"{address.looked_at()}\n" + f"nothing is there now, so an effect these packs name will fail closed; " + f"`sayfirst-daemon up --quickstart` starts a control plane at that address\n" + ) def _write_problem(failure: SocketClientProblem, stderr: TextIO) -> int: diff --git a/src/sayfirst_cli/instrument/designation.py b/src/sayfirst_cli/instrument/designation.py new file mode 100644 index 0000000..572d281 --- /dev/null +++ b/src/sayfirst_cli/instrument/designation.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: Apache-2.0 +"""How a pack is designated on the command line: by a path, or by the name it ships under. + +Article 9: a pack is « explicitly designated by the user », and there is no +registry. Both halves bind here, and this file is where the line between them +is drawn. + +**A path is a directory, read as it always was.** Any designation with a path +separator in it — and `.` and `..`, which are paths with none — names a +directory a person chose, exactly as they would name a file to run. + +**A bare word is the name of a pack THIS DISTRIBUTION ships, and nothing else.** +It is looked up in one place: the package data installed beside this module, +the same set `sayfirst packs list` prints. That is a designation — the person +typed the pack they want — and it is not a registry, because every property a +registry would have is absent on purpose: + +* no search path. One location is read, and it is not configurable: no + environment variable, no file, no directory of the user's is consulted. +* no fallback between the two readings. Which one applies is decided by the + SPELLING alone, before the file system is looked at, so the same words mean + the same thing in every directory. A bare word is never tried as a directory + of the working directory — a directory somebody left beside a program cannot + become the code that runs in front of it — and a path is never tried as a + name. +* no default set. A pack nobody designated is not installed. +* no name anybody else can publish under. The set is closed by what this + distribution carries; a pack of one's own is designated by its path. + +A word that names nothing shipped is refused, saying what is shipped and how a +directory is spelled. It is refused rather than retried as a path for the +reason above, and because « not found, so something else was used » is the one +answer a designation must never give. + +(Like the modules beside it, this file names no pack and no library: the names +are whatever the package data holds, read when they are asked for.) +""" + +from __future__ import annotations + +import importlib.resources +import os +from pathlib import Path +from typing import Final + +from . import manifest + +#: The package whose data is the packs this distribution ships. +SHIPPED_PACKAGE: Final[str] = "sayfirst_cli.packs" + +#: What `--pack` says about itself, in one place for the two verbs that take it. +HELP: Final[str] = ( + "a pack: the name of one this distribution ships (`sayfirst packs list`), or the path " + "of a pack directory, spelled with a separator (./own-pack); repeat for each pack" +) + +#: The two spellings that are paths without a separator in them. +_RELATIVE: Final[frozenset[str]] = frozenset({".", ".."}) + + +def shipped_packs() -> list[Path]: + """Every pack directory this installed distribution carries, sorted by path. + + A directory counts as a pack candidate here by carrying a manifest, not by + its name — `__pycache__` and anything else beside the packs is silently + not a pack rather than a reason this call fails; `manifest.read_pack` + still refuses one that names a manifest but is not, in fact, complete. + + Read from the *installed* package with `importlib.resources`, never from a + path built off this file's own location: the packs this answers with are + the ones this distribution actually carries, wherever it was installed. + """ + root = importlib.resources.files(SHIPPED_PACKAGE) + if not root.is_dir(): + return [] + return sorted( + Path(str(item)) + for item in root.iterdir() + if item.is_dir() and (item / manifest.MANIFEST_FILE).is_file() + ) + + +def is_a_path(designation: str) -> bool: + """Whether a designation is spelled as a path, asked of the spelling alone.""" + separators = (os.sep, os.altsep) if os.altsep else (os.sep,) + return designation in _RELATIVE or any(mark in designation for mark in separators) + + +def directory_of(designation: str) -> Path: + """The directory a designation names, or the refusal that says why it names none. + + A path is answered as it was typed and is not looked at here: whether it + holds a pack is `manifest.read_pack`'s question, asked next, with its own + sentences. A name is answered with the shipped directory of that name. + """ + if is_a_path(designation): + return Path(designation) + shipped = {path.name: path for path in shipped_packs()} + if designation in shipped: + return shipped[designation] + names = ", ".join(sorted(shipped)) or "none" + raise manifest.PackInvalid( + f"no pack of that name ships with this distribution (it ships: {names}). A bare " + f"word is the name of a shipped pack and is never read as a directory; a pack of " + f"your own is designated by a path with a separator in it, as in ./{designation}" + ) + + +def read(designation: str) -> manifest.Pack: + """The pack a designation names, read the way the engine will read it.""" + return manifest.read_pack(directory_of(designation)) diff --git a/src/sayfirst_cli/instrument/engine.py b/src/sayfirst_cli/instrument/engine.py index 5c3930c..3905551 100644 --- a/src/sayfirst_cli/instrument/engine.py +++ b/src/sayfirst_cli/instrument/engine.py @@ -173,24 +173,38 @@ def uninstall(self) -> None: self._boundary = None self._installed = False - def _replace(self, loaded: ModuleType, point: Point, wrapper: Wrapper) -> None: - """Replace one attribute of a module that has just finished loading. - - The one path that can refuse after the program has started, because the - attribute did not exist to be absent until now. - """ - original = _original_of(loaded, point) - setattr(loaded, point.attribute, _made(wrapper, original, point, self._boundary)) - self._taken.append((loaded, point.attribute, original)) - def _awaiting(self, name: str) -> bool: """Whether any point is waiting for this module to be loaded.""" return name in self._awaited def _arrived(self, name: str, loaded: ModuleType) -> None: - """Called by the wrapped loader, once, after the module's own body has run.""" - for point, wrapper in self._awaited.pop(name, []): - self._replace(loaded, point, wrapper) + """Called by the wrapped loader, once, after the module's own body has run. + + The one path that can refuse after the program has started, because the + attribute did not exist to be absent until now — so it is all or + nothing here for the same reason it is in `install`: every replacement + is made before the first attribute is replaced, and the module's points + stop waiting only once every one of them is in place. + + A refusal therefore leaves them waiting. The import it refused is + removed from `sys.modules` by the interpreter, and a program that + catches that failure and imports again has to meet the same refusal: + measured before this rule, the points were dropped on the way in, the + finder no longer claimed the module, and the retry succeeded entirely + unwrapped — the refusal had turned governance off for the module it + refused. + """ + waiting = self._awaited.get(name) + if waiting is None: + return + prepared: list[tuple[Point, object, object]] = [] + for point, wrapper in waiting: + original = _original_of(loaded, point) + prepared.append((point, original, _made(wrapper, original, point, self._boundary))) + del self._awaited[name] + for point, original, replacement in prepared: + setattr(loaded, point.attribute, replacement) + self._taken.append((loaded, point.attribute, original)) class _Finder: diff --git a/src/sayfirst_cli/instrument/follow.py b/src/sayfirst_cli/instrument/follow.py new file mode 100644 index 0000000..d22f639 --- /dev/null +++ b/src/sayfirst_cli/instrument/follow.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`--follow-children`: a governed program's Python children govern their effects too. + +The engine installs the boundary in ONE process — the one the launcher hands the +program to. A program that spawns another Python (a worker, a tool, a step of +its own) starts a fresh interpreter with no boundary in it, and every effect +that child makes reaches the world unasked. `instrument run` already governs the +SPAWN itself (the subprocess pack asks before the child is created); what it does +not do, on its own, is govern what the child then does. + +`--follow-children` closes that, for `run`, by the mechanism a fresh interpreter +offers for running code before a program's own: a `sitecustomize` on its import +path. When the flag is set, the launcher prepends this package's `_bootstrap` +directory to `PYTHONPATH` and writes the run's configuration into the +environment. A Python child that starts normally WITH THAT ENVIRONMENT then +imports the `sitecustomize` there, which installs the same packs, against the +same daemon, before the child's code runs — and leaves the environment in +place, so a grandchild started the same way is followed too. + +**A child that installs the boundary and cannot, fails closed.** Once +`sitecustomize` runs, a child that cannot install the boundary — the daemon +unreachable at import, a pack that will not read, this client not importable in +the child — raises out of `sitecustomize` and the child interpreter exits before +the program runs. A followed child that got that far makes its effect asked +about or does not run. + +**What the mechanism cannot reach, stated rather than hidden.** The install +rides on the environment and the interpreter's ordinary start-up, so a child +that does not start with both is not followed and runs as it would have +WITHOUT the flag — the same as a child of a run that did not pass +`--follow-children`: + +* a child started with `-S` (no site), `-I` (isolated) or `-E` (ignore the + environment) never imports the `sitecustomize`; +* a child whose environment REPLACES `PYTHONPATH` — the everyday + `env={**os.environ, "PYTHONPATH": …}` — or is built from nothing never sees + the bootstrap directory on it, though the configuration variable may still be + there. + +This is the boundary of an environment-and-start-up mechanism, not a hole the +flag opens: the child's own SPAWN was still governed by the pack in the parent +(the parent asked before creating it), and what such a child then does is +beyond the reach of anything installed through the environment. It is a limit, +not a fail-open, and the regression suite pins both shapes so they stay known +limits rather than surprises. + +**What it needs, and what it does not reach.** The child must be able to import +this client, the boundary and the contract. That holds when they are installed +in the interpreter the child runs (the ordinary `uv tool install` case). It does +NOT hold when this command was itself lent those packages for a target +interpreter (`interpreter.py`): the lending lives in one process's meta-path and +a spawned child is a fresh process, so `--follow-children` with a named +interpreter that lacks an install is refused rather than silently followed by +nothing. A non-Python child is not a Python child: the spawn was governed, and +there is no interpreter to install into. + +**Only `run`.** `verify` proves one process from its own audit hook, and a +spawned child is a separate process the hook cannot see (`harness.py` counts such +a child as coverage this run could not judge). Following children would govern +them without letting the proof see them, which is why the flag is `run`'s. +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Final + +from .manifest import Pack + +#: The environment member that carries the run's configuration to a child. Its +#: presence is what tells a `sitecustomize` this is a followed run. +CONFIG_VARIABLE: Final[str] = "SAYFIRST_FOLLOW_CHILDREN" + +#: The directory whose `sitecustomize.py` a child imports at startup. It ships +#: beside this module, in the installed distribution, and carries nothing but +#: that one file. +BOOTSTRAP_DIRECTORY: Final[str] = "_bootstrap" + + +def bootstrap_path() -> Path: + """Where the child's `sitecustomize` lives, in this installed distribution.""" + return Path(__file__).resolve().parent / BOOTSTRAP_DIRECTORY + + +def configuration( + packs: Sequence[Pack], + *, + socket: str, + scope: str, + mode: str, + daemon_user: str | None, + principal: str | None, +) -> str: + """The run's configuration a child reads back, as JSON. + + The pack DIRECTORIES, not the packs: a child reads each again, because it + shares no state with this process and a pack that stopped reading between the + parent and the child is the child's to refuse. Everything a child needs to + build the same profile and the same boundary, and nothing about verification + — a followed child is governed, never proven. + + **The directories are made absolute.** A child spawned in a working + directory of its own — an ordinary `subprocess.run(..., cwd=…)` — reads a + relative `./own-pack` against ITS cwd, not the parent's, and finds no pack + there: it would fail closed on a pack that was fine where the parent + designated it. Resolved here, once, against the parent's working directory, + so the name a child reads is the pack the parent chose. + """ + return json.dumps( + { + "packs": [str(pack.directory.resolve()) for pack in packs], + "socket": socket, + "scope": scope, + "mode": mode, + "daemon_user": daemon_user, + "principal": principal, + }, + sort_keys=True, + ) + + +def environment_for( + base: Mapping[str, str], + packs: Sequence[Pack], + *, + socket: str, + scope: str, + mode: str, + daemon_user: str | None, + principal: str | None, +) -> dict[str, str]: + """`base` with the child bootstrap on the import path and the run configured. + + `PYTHONPATH` gets this package's `_bootstrap` directory at its HEAD, so the + child imports that `sitecustomize` and not another; the directory holds only + `sitecustomize.py`, so nothing else of this client's is put on a child's + path by being there. Everything else is left as it was — `PYTHONSAFEPATH` + included, which keeps a script's own directory off the head of the path and + does not stop a `sitecustomize` on `PYTHONPATH` from loading. + """ + updated = dict(base) + bootstrap = str(bootstrap_path()) + existing = updated.get("PYTHONPATH", "") + updated["PYTHONPATH"] = f"{bootstrap}{os.pathsep}{existing}" if existing else bootstrap + updated[CONFIG_VARIABLE] = configuration( + packs, + socket=socket, + scope=scope, + mode=mode, + daemon_user=daemon_user, + principal=principal, + ) + return updated diff --git a/src/sayfirst_cli/instrument/harness.py b/src/sayfirst_cli/instrument/harness.py index 8ef0995..16a1b16 100644 --- a/src/sayfirst_cli/instrument/harness.py +++ b/src/sayfirst_cli/instrument/harness.py @@ -24,17 +24,26 @@ third is never a pass, and this file never turns it into one — it counts events and writes them down; the command reads the report and decides the exit. -**One thing more is written down, and it is a COUNT rather than a fourth -word.** An event can arrive that this proof cannot judge at all: its argument -names one of the program's own start files after the program has begun, which -is the one shape `Watch` cannot tell from the hand-off's own reading of the -same file. Such an event is counted on the point it would have been judged -against and published as `unjudged`, beside a verdict that stays about the -events that WERE judged. It is not a verdict and not one of the three words: -every way of spelling it as one says something the run did not measure. What it -does is refuse the run a pass — `verify` cannot answer 0 while any count is -non-zero — which is the direction article 2 requires, and the direction this -rule failed in for as long as the same events were silently dropped. +**The one rule every one of those three answers to.** A point is `governed` +only from evidence this run actually READ, actually found SOUND, and actually +tied to the effect it watched — and only over a run it watched WHOLE. It is +`ungoverned` only where the chain was read to its END and holds no record of +this run's, because that word is a finding this client made and a finding needs +the whole chain. Everything else — evidence the chain's own verification does +not report intact, a walk that stopped half way, a record this run cannot tell +from another execution's, an effect that reached the world along a path no point +interposes, a fork whose child's observations are in a memory this process +cannot read — is an INCOMPLETENESS, and an incompleteness is neither. + +**So one thing more is written down, and it is a COUNT rather than a fourth +word.** Every incompleteness above is counted on the point it touches and +published as `unjudged`, with its reason beside it, next to a verdict that stays +about the observations that WERE judged. It is not a verdict and not one of the +three words: every way of spelling it as one says something the run did not +measure. What it does is refuse the run a pass — `verify` cannot answer 0 while +any count is non-zero — which is the direction article 2 requires, and the +direction this rule failed in for as long as each of those was silently dropped, +counted as a pass, or published as a finding nobody had the information to make. **A fourth thing can happen, and it is not a verdict.** The chain may be unreadable while the program runs. « No record exists » and « this client could @@ -70,17 +79,22 @@ import atexit import json import os +import pwd import sys import threading import time +import tomllib +import uuid from collections.abc import Callable, Iterator, Mapping, Sequence from contextlib import contextmanager, suppress from dataclasses import dataclass, field from pathlib import Path -from typing import Final +from typing import Final, NamedTuple from sayfirst_contract.client import Answered, Result -from sayfirst_contract.problems import Problem, ProblemCode +from sayfirst_contract.decisions import Outcome +from sayfirst_contract.evidence import ChainCondition +from sayfirst_contract.problems import Problem, ProblemCode, problem_class_of from sayfirst_contract.transport.socket_client import ( ProfileMisuse, SocketClientProblem, @@ -122,9 +136,68 @@ UNJUDGED: Final[str] = "unjudged" #: The kind of chain entry that records an effect, and the outcome that let it -#: happen. Both are the plane's own words, read and never translated. -EFFECT: Final[str] = "effect" -ALLOW: Final[str] = "allow" +#: happen. Both are the plane's own words, read and never translated — taken +#: from where they are published rather than spelled again here. +EFFECT: Final[str] = pages.EFFECT +ALLOW: Final[str] = Outcome.ALLOW.value + +#: What a served verification says about a range a record may be taken from. +#: The contract publishes four conditions and exactly one of them is « the +#: writer verified this range »; the other three are a break, a declared gap +#: and « could not tell ». A record read out of any of those is evidence its own +#: writer declined to stand behind, and a conclusion drawn from it would be a +#: claim stronger than the evidence held (article 2). +INTACT: Final[str] = ChainCondition.intact.value + +#: The member a point may declare naming the audit events by which an effect of +#: its kind reaches the world along a path the point does NOT interpose. +#: Declared by the pack, because a pack is the one place a library's vocabulary +#: may be written down (article 4) — and read here rather than through +#: `manifest.Point` for the reason `uninterposed_events` gives. +UNINTERPOSED: Final[str] = "uninterposed_events" + +#: The member a point may declare naming, among its uninterposed events, the +#: ones its OWN interposed call raises on the way to the effect it was asked +#: for — the same act, reached through that call's implementation. Declared by +#: the pack for the same reason, and read the same way (`inner_events`). +INNER: Final[str] = "inner_events" + +#: What the report calls the reasons behind a point's `unjudged` count. The +#: count is what refuses the run a pass; the reasons are what let a reader act +#: on it, and a count with no reason is a number nobody can do anything about. +INCOMPLETE: Final[str] = "incomplete" + +#: Why one observation could not be judged. Five shapes, written out rather +#: than summarised, because « the evidence was damaged », « the walk stopped +#: half way », « the decision may be another execution's », « the effect took a +#: path nothing watches » and « the observation is in a child's memory » are +#: five different facts about five different things, and a reader who is told +#: only « 1 unjudged » cannot tell which of them happened. +A_START_FILE: Final[str] = ( + "an argument named one of the program's own start files after the program had started, " + "which this proof cannot tell from the import system finishing with that same file" +) +EVIDENCE_NOT_INTACT: Final[str] = ( + "the record for this effect sits in a range the chain's own verification does not report " + "intact, so it is evidence the writer of the chain declined to stand behind" +) +ANOTHER_EXECUTIONS_RUN: Final[str] = ( + "the record for this effect does not carry this run's correlation, so it may be a decision " + "another execution of this principal obtained" +) +WALK_INTERRUPTED: Final[str] = ( + "the chain was read and the walk did not reach its end, so no record was found and no " + "absence was established either" +) +PATH_NOT_INTERPOSED: Final[str] = ( + "an effect of a kind this pack names reached the world through an event no point of that " + "pack interposes, so this run neither judged it nor stopped it — instrumentation can miss " + "a call, and this is that limit counted rather than dropped" +) +THE_RUN_FORKED: Final[str] = ( + "this run forked, and what a child observed lives in a memory this report was not written " + "from, so no point of it covers the whole run" +) #: How long one consultation waits for the record of an effect to appear. #: Article 10 writes the chain asynchronously; see the module docstring for why @@ -217,6 +290,22 @@ class ChainUnreadable(RuntimeError): """ +class NotJudged(RuntimeError): + """An effect this run could not judge, aborted rather than let through. + + It is deliberately NOT the exception an ungoverned effect raises, and it + carries no finding: `Watched.refused` stays false and the point's verdict + stays about the events that WERE judged. What it leaves behind is a count + and a reason, which is what refuses the run a pass without inventing a + finding nobody made (article 2). + + It is a `RuntimeError` so that a program written to survive the abort of an + ungoverned effect survives this one the same way: the two are one event + from inside the program — an effect was stopped — and differ only in what + this client may say about it afterwards. + """ + + @dataclass class Watched: """One interposition point, and what this run observed about it.""" @@ -225,12 +314,24 @@ class Watched: point: manifest.Point events: int = 0 refused: bool = False - #: Events on this point that this run could not judge, because an argument - #: named one of the program's own start files after the gate had opened — - #: the one shape `Watch` cannot tell from the hand-off's own reading of the - #: same file. Counted rather than dropped, and carried beside the verdict - #: rather than folded into it: the command reads it and cannot answer 0. + #: Observations on this point that this run could not conclude anything + #: from. Counted rather than dropped, and carried beside the verdict rather + #: than folded into it: the command reads it and cannot answer 0. unjudged: int = 0 + #: Why, one sentence per distinct reason and each kept once. The count is + #: what the command reads; these are what a person reads. + reasons: list[str] = field(default_factory=list) + + def not_judged(self, reason: str) -> None: + """Count one observation this run could not conclude anything from. + + Every way of turning one of these into a verdict says something the run + did not measure, so none of them does: the count goes up, the reason is + kept, and `verify._exit_for` is what refuses the pass. + """ + self.unjudged += 1 + if reason not in self.reasons: + self.reasons.append(reason) @property def verdict(self) -> str: @@ -265,6 +366,7 @@ def to_document(self) -> dict[str, object]: "verdict": self.verdict, "events": self.events, UNJUDGED: self.unjudged, + INCOMPLETE: list(self.reasons), } @@ -369,6 +471,17 @@ def __init__(self) -> None: #: has to be able to tell « this program walked no such path » from #: « this proof never began watching » afterwards. self.ever_started = False + #: Whether this run forked while the program was running. A fork copies + #: this object, the chain's position and every `Watched`; what the child + #: then observes changes ITS copies, in a memory the parent that writes + #: the report cannot read. The parent can see that it happened, and + #: that is what it says (`_forked` says what it costs not to). + self.forked = False + #: Whether THIS process is such a child. It shares the parent's report + #: and outcome paths, and a child that wrote them would replace the + #: parent's findings with its own view of a run the parent is still + #: concluding. + self.in_a_forked_child = False self._starts: frozenset[str] = frozenset() self._derived: frozenset[str] = frozenset() self._ours = threading.local() @@ -542,33 +655,112 @@ class Configuration: class Consultation: """What one consultation of the chain established, and what it could not. - Three outcomes rather than two, which is article 2's rule about a status - surface applied to a single read: a record was found, no record was found - in a chain that WAS read, or the chain was not read at all. Only the second - is a finding about the program. + More than two outcomes, which is article 2's rule about a status surface + applied to a single read. A record was found; or the chain was read to its + END and holds no record of this run's, which is the one outcome that is a + finding about the program; or the chain was not read at all; or it was read + in part and the walk never reached the end, which establishes nothing; or a + record was seen and could not be used, which establishes nothing either. """ matched: int | None read_something: bool + #: Whether any walk inside the patience reached the chain's end. Only then + #: has « no record exists » been established: a walk that stopped half way + #: has read the part it read and says nothing about the rest, and a finding + #: made from it is a negative fact published on incomplete information. + established_absence: bool problem: Problem | None + #: A record this consultation saw and could not take, and why. Never a + #: finding and never a pass: it is the third value. + unusable: str | None + #: Whether a record for this capability exists in the scope that another + #: principal obtained. Said on the error stream, so a reader is not left + #: wondering why an allow they can see in the chain answered for nothing. + foreign: bool @dataclass class Chain: - """The scope's evidence, as far as this harness has consumed it. - - The cursor is the sequence of the last entry a consultation matched. A read - starts after it, so one recorded effect cannot answer for two events: the - second consultation never sees it again. + """The scope's evidence, and what this run has taken from it. + + **The floor never moves.** It is the position the chain had before the + program started, and every read begins after it. What moves instead is + `spent`: the sequences a consultation actually USED. A single moving cursor + spent everything it stepped over as well, so a record matched out of + decision order buried every earlier one behind it — and the effect an + earlier record covered was then published as a finding against a decision + sitting in the chain (`_matching` says what that cost). + + **Whom a record has to belong to.** A record supports an effect of this run + only if the plane recorded it for the account this process runs as + (article 6: identity is the operating system's, and the peer of the + boundary's connection is this process). `one_execution` says whether the + decisions this run looks for were taken BY this run: under the shipped, + governed hand-off they were, so every record taken must also come from one + connection — the one this run's own boundary holds. Under `--ungoverned` + the program runs with nothing in front of it and the chain alone answers, + so the records were written by another execution by construction and no + connection may be required of them. """ connection: VerifiedConnection scope: str - cursor: int + #: The position the chain had before the program started. It never moves. + floor: int #: One consultation at a time, because an audit event can arrive on any #: thread and two of them sharing one HTTP connection would interleave two #: reads on one socket. lock: threading.Lock = field(default_factory=threading.Lock) + #: The sequences consultations have USED, so one recorded effect cannot + #: answer for two events and a record nobody used stays reachable. + spent: set[int] = field(default_factory=set) + #: How the plane names the account this process runs as. + principals: frozenset[str] = field(default_factory=lambda: _this_executions_principals()) + #: Whether this run's own boundary is what asked (see the class docstring). + one_execution: bool = False + #: The correlation this run's boundary stamped on every ask, or None under + #: `--ungoverned` (no boundary asked). A record must carry it to be this + #: run's. The shipped boundary holds one connection per grant, so a run + #: spans several connections and a record's connection cannot stand for its + #: run; this token is one value per run, known before the first record is + #: read, so it checks the first record too. + correlation: str | None = None + + +def _this_executions_principals() -> frozenset[str]: + """How the plane names the account this process runs as, in every spelling. + + The daemon builds a principal from the peer credential of the connection and + records it under the account's name, or under the uid where the directory + could not name one. Both are computed here, so a record naming either is + recognised and a record naming neither is somebody else's. + + A lookup that cannot be made leaves the uid, which is the one spelling no + directory is needed for. The set is never empty: a run that could not say + who it is must refuse every record rather than accept them all. + """ + uid = os.geteuid() + named = {str(uid)} + # A uid with no account keeps the uid, which is the spelling no directory + # is needed for; the set is never empty. + with suppress(KeyError, OSError): + named.add(pwd.getpwuid(uid).pw_name) + return frozenset(named) + + +@dataclass(frozen=True) +class Walk: + """One walk of the chain for one capability: what it found, and how far it got.""" + + matched: int | None + #: Whether any page was answered at all. + answered: bool + #: Whether the walk reached the chain's end, which is what an absence needs. + whole: bool + problem: Problem | None + unusable: str | None + foreign: bool def _write_outcome( @@ -611,6 +803,10 @@ def _write_outcome( if problem is not None: code = problem.code said["problem_code"] = code.value if isinstance(code, ProblemCode) else code.raw + # And its class, asked of the value: the plane refusing the read and + # this client failing to read the answer are 3 and 4, and the same code + # is minted on both sides of the wire, so the code cannot say which. + said["problem_class"] = problem_class_of(problem) with suppress(OSError): path.write_text(json.dumps(said) + "\n", encoding="utf-8") @@ -640,7 +836,7 @@ def main(argv: Sequence[str] | None = None) -> int: sys.stderr.write(f"{misuse}\n") return exit_codes.EXIT_MISUSE try: - connection = connect(configured.profile) + connection = connect(configured.profile, timeout=reads.READ_TIMEOUT) except SocketClientProblem as failure: # No question was ever put and no chain was ever read, so there is no # report to write: nothing was proven and nothing is claimed. @@ -672,30 +868,70 @@ def _prove( for item in watched: by_event.setdefault(item.point.audit_event, []).append(item) watch = Watch() - chain = Chain(connection, configured.profile.scope, cursor=0) + + def say(outcome: str, detail: str, *, problem: Problem | None = None) -> None: + """This run's own ending, written by the process that IS the run. + + A forked child reaching one of these lines is the program's copy of this + harness and not a second verification: it holds the parent's paths, and + writing them would replace the parent's findings with a child's view of + a run the parent is still concluding. + """ + if watch.in_a_forked_child: + return + _write_outcome(configured.outcome, outcome, detail, problem=problem) + + try: + uninterposed = _the_paths_no_point_interposes(packs, watched) + inner = _the_inner_events(packs) + except manifest.PackInvalid as misuse: + # A declaration this reader cannot read is the invocation's mistake and + # not the program's: nothing has run and no question was ever put. + say(INVOCATION_REFUSED, str(misuse)) + sys.stderr.write(f"{misuse}\n") + return exit_codes.EXIT_MISUSE + # One token for this run, generated here and handed to the boundary through + # the launcher, so a record the plane stamped with it is one this run + # produced. Only under the governed hand-off, where this run's own boundary + # asks; under `--ungoverned` no boundary asks and the chain alone answers. + correlation = f"sayfirst-verify:{uuid.uuid4()}" if configured.governed else None + chain = Chain( + connection, + configured.profile.scope, + floor=0, + one_execution=configured.governed, + correlation=correlation, + ) with watch.ours(): head = _head_of_the_chain(chain) if isinstance(head, Problem): - _write_outcome(configured.outcome, CHAIN_UNREADABLE_BEFORE, head.message, problem=head) + say(CHAIN_UNREADABLE_BEFORE, head.message, problem=head) sys.stderr.write(f"the chain could not be read, so nothing was verified: {head.message}\n") return exit_codes.EXIT_COULD_NOT_ASK - chain.cursor = head - 1 + chain.floor = head - 1 + # A fork copies everything this run concludes from, into a memory the + # process that writes the report cannot read. Registered before the hook, + # so that a program forking on its first line is already covered. + os.register_at_fork( + after_in_parent=lambda: _forked(watch, watched), + after_in_child=lambda: _in_a_forked_child(watch), + ) # Installed before the hand-off — and before the engine, so an interpreter # that could be asked to forget a hook cannot make the proof optional. What # draws the line between this harness's work and the program's is the # ARMING, which the launcher does at the last instant (`Watch` says why). - sys.addaudithook(_consulting(chain, by_event, watch)) + sys.addaudithook(_consulting(chain, by_event, uninterposed, watch, inner)) ending: int | None = None refused_the_invocation = False try: - ending = _run_the_target(configured, packs, target, watch) + ending = _run_the_target(configured, packs, target, watch, correlation) except HarnessMisuse as misuse: # Before the hand-off every failure is this invocation's and the program # has not started (`launch.py` draws that line): there is nothing to # report about a program that never ran, and findings written anyway # would say `not-exercised` about a path no program was there to walk. refused_the_invocation = True - _write_outcome(configured.outcome, INVOCATION_REFUSED, str(misuse)) + say(INVOCATION_REFUSED, str(misuse)) sys.stderr.write(f"{misuse}\n") return exit_codes.EXIT_MISUSE except engine.EngineMisuse as misuse: @@ -706,7 +942,7 @@ def _prove( # refusal `instrument run` gives the same mistake, and the command that # spawned this reads it off the outcome rather than off a number. refused_the_invocation = True - _write_outcome(configured.outcome, INVOCATION_REFUSED, str(misuse)) + say(INVOCATION_REFUSED, str(misuse)) sys.stderr.write(f"{misuse}\n") return exit_codes.EXIT_MISUSE except ChainUnreadable: @@ -722,12 +958,16 @@ def _prove( # and the traceback the interpreter is about to render. _wait_for_the_program(chain) watch.disarm() - if not refused_the_invocation and watch.unreadable is None and watch.ever_started: + if ( + not refused_the_invocation + and watch.unreadable is None + and watch.ever_started + and not watch.in_a_forked_child + ): with watch.ours(): _write_report(configured, packs, watched, head, ending, connection) if watch.unreadable is not None: - _write_outcome( - configured.outcome, + say( CHAIN_UNREADABLE_DURING, watch.unreadable.message, problem=watch.unreadable, @@ -743,13 +983,13 @@ def _prove( # `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). - _write_outcome(configured.outcome, GATE_NEVER_OPENED, " ".join(target)) + say(GATE_NEVER_OPENED, " ".join(target)) sys.stderr.write(f"{NEVER_STARTED}\n") - return exit_codes.EXIT_COULD_NOT_ASK + 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 # reader ever sees the sentence — a path nobody can open is worse than none. - _write_outcome(configured.outcome, REPORTED, "") + say(REPORTED, "") return exit_codes.EXIT_ALLOW @@ -804,17 +1044,232 @@ def _wait_for_the_program(chain: Chain) -> None: Then the lock, which a consultation holds while it polls: findings written out from under a consultation still in flight would be findings made without the answer they were waiting for. + + **And first of all, the callbacks the interpreter runs before it joins + anything.** That order is not a detail: a worker parked on an empty queue + is woken by a shutdown callback and by nothing else, and joining it before + the callback ran waits for a thread nobody has told to stop. + `_wake_what_the_interpreter_would_wake` says what that cost. """ + _wake_what_the_interpreter_would_wake() _join_the_programs_threads() # Not guarded against an exception: `atexit` prints what a handler raised # and goes on to the next, exactly as the interpreter does, so there is # nothing here to catch that it has not already reported. atexit._run_exitfuncs() + # Again, because a handler can have started a pool of its own, exactly as + # the second join exists because a handler can have started a thread. + _wake_what_the_interpreter_would_wake() _join_the_programs_threads() with chain.lock: pass +def _wake_what_the_interpreter_would_wake() -> None: + """Run the shutdown callbacks the interpreter runs BEFORE it joins a thread. + + `threading` keeps a register of its own, separate from `atexit`, for the + work that has to happen while the threads are still there to be told. The + interpreter runs it first, then joins every non-daemon thread, and only + then reaches the `atexit` handlers. A pool of workers uses exactly that + order: its workers are parked on a queue that only its own callback puts a + sentinel on, and its threads are not daemons. + + This function ran nowhere, and the join came first. Measured on a program + whose library held a pool at module scope, finished its one task and + returned normally: the program printed its last line, the join waited on a + worker nobody had woken, and the verification sat there until the command's + own fifteen-minute bound — reported afterwards as a run that concluded + nothing, about a program that had in fact finished. A library holding a + pool is the ordinary shape of the runtimes this chain exists to instrument. + + Read off `threading` rather than kept here, and tolerated absent: this is + the interpreter's own register, and a build that does not publish it leaves + a run exactly where it was before — which is a bound rather than a hang, + because the command has one. + + A callback's own failure is not this harness's to repair. It is suppressed + and the rest are run, because the interpreter would reach every one of them + moments later and this process has a report to write either way. + """ + callbacks = getattr(threading, "_threading_atexits", None) + if not callbacks: + return + for call in list(callbacks): + with suppress(Exception): + call() + + +def _forked(watch: Watch, watched: Sequence[Watched]) -> None: + """The parent's side of a fork: say that this report does not cover the run. + + A fork copies the hook, the chain's position and every `Watched` into a + memory the parent cannot read. The child goes on being watched — it aborts + an effect no record covers, exactly as the parent would — and then its + findings end with it. Measured before this existed: a worker that met an + ungoverned effect, caught the abort and exited 0 was joined by a parent + that reported `governed`, exit 0, over a run in which an effect had been + refused. + + Collecting a child's findings is not something this side of a fork can do. + Saying that they are missing is, and that is the whole of this function: one + count on every point, once per run, because « this report does not cover the + child » is one fact about the run and not one per fork. + """ + if not watch.judging or watch.in_a_forked_child or watch.forked: + return + watch.forked = True + for item in watched: + item.not_judged(THE_RUN_FORKED) + sys.stderr.write(f"{NOT_JUDGED}: {THE_RUN_FORKED}\n") + + +def _in_a_forked_child(watch: Watch) -> None: + """The child's side: this process is a copy and writes none of the run's files.""" + watch.in_a_forked_child = True + + +def uninterposed_events(pack: manifest.Pack) -> dict[int, tuple[str, ...]]: + """The paths a pack declares that it does NOT interpose, by the point declaring them. + + A pack is the one place a library's vocabulary may be written down (article + 4), so the events by which an effect of a pack's kind can reach the world + are the pack's to name. A point that names none claims none, and a run over + such a pack accounts for exactly what it did before. + + **Read from the manifest here, rather than carried on `manifest.Point`.** + The two readers answer two questions. `manifest` reads what the ENGINE + installs, and this member declares precisely what no engine installs: a path + the pack deliberately leaves alone. Handing it to the engine's own point + would give the engine a member it has to ignore, and a member an engine + ignores is how an engine comes to install one by accident. What that costs + is one more read of one file, by the reader that needs it, at the start of a + run — and `PackInvalid` is raised for a declaration this reader cannot read, + so the refusal is the same refusal the same mistake gets everywhere else. + """ + manifest_file = pack.directory / manifest.MANIFEST_FILE + try: + document = tomllib.loads(manifest_file.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, RecursionError) as unreadable: + raise manifest.PackInvalid( + f"{manifest_file} does not read as a manifest: {unreadable}" + ) from unreadable + declared: dict[int, tuple[str, ...]] = {} + points = document.get("point") + if not isinstance(points, list): + return declared + interposed = {point.audit_event for point in pack.points} + for index, entry in enumerate(points): + if not isinstance(entry, dict) or UNINTERPOSED not in entry: + continue + named = entry[UNINTERPOSED] + if ( + not isinstance(named, list) + or not named + or not all(isinstance(event, str) and event for event in named) + ): + raise manifest.PackInvalid( + f"[[point]] {index + 1} {UNINTERPOSED} is {named!r}: a point names the audit " + f"events by which an effect of its kind reaches the world along a path it does " + f"not interpose, as a non-empty list of event names — a verifier that cannot " + f"read them would drop the very effects it exists to account for" + ) + borrowed = sorted(set(named) & interposed) + if borrowed: + raise manifest.PackInvalid( + f"[[point]] {index + 1} {UNINTERPOSED} names {borrowed[0]!r}, which a point of " + f"this pack interposes: one event is watched or it is missed, and a pack " + f"declaring it both ways declares nothing this verifier can act on" + ) + declared[index] = tuple(dict.fromkeys(named)) + return declared + + +def inner_events(pack: manifest.Pack) -> dict[int, tuple[str, ...]]: + """The uninterposed events a pack declares its own interposed call raises, by point. + + A call a point interposes can reach its effect through an event the same + pack names as a path it does not interpose — a library creating the process + it was asked for through a lower-level call the pack also names. Counting + that event would report the one act the point judged as a second act nobody + judged. Which events those are is the library's vocabulary, so the pack + names them (article 4), and `the_same_act` says when one is paired. + + An inner event must be one the same point names as uninterposed: pairing + only ever stops a named event from being counted, and a declaration of one + side alone declares nothing this verifier can act on. + """ + manifest_file = pack.directory / manifest.MANIFEST_FILE + try: + document = tomllib.loads(manifest_file.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, RecursionError) as unreadable: + raise manifest.PackInvalid( + f"{manifest_file} does not read as a manifest: {unreadable}" + ) from unreadable + declared: dict[int, tuple[str, ...]] = {} + points = document.get("point") + if not isinstance(points, list): + return declared + uninterposed = uninterposed_events(pack) + for index, entry in enumerate(points): + if not isinstance(entry, dict) or INNER not in entry: + continue + named = entry[INNER] + if ( + not isinstance(named, list) + or not named + or not all(isinstance(event, str) and event for event in named) + ): + raise manifest.PackInvalid( + f"[[point]] {index + 1} {INNER} is {named!r}: a point names the events its own " + f"interposed call raises on the way to its effect, as a non-empty list of event " + f"names" + ) + stray = sorted(set(named) - set(uninterposed.get(index, ()))) + if stray: + raise manifest.PackInvalid( + f"[[point]] {index + 1} {INNER} names {stray[0]!r}, which the same point does " + f"not name in {UNINTERPOSED}: pairing only stops a named event from being " + f"counted, so an inner event is always one of the point's uninterposed events" + ) + declared[index] = tuple(dict.fromkeys(named)) + return declared + + +def _the_inner_events(packs: Sequence[manifest.Pack]) -> dict[str, frozenset[str]]: + """For each interposed event, the inner events of the points that interpose it.""" + inner: dict[str, frozenset[str]] = {} + for pack in packs: + declared = inner_events(pack) + for index, point in enumerate(pack.points): + named = declared.get(index) + if named: + inner[point.audit_event] = inner.get(point.audit_event, frozenset()) | set(named) + return inner + + +def _the_paths_no_point_interposes( + packs: Sequence[manifest.Pack], watched: Sequence[Watched] +) -> dict[str, list[Watched]]: + """Every declared uninterposed event, against the points whose coverage it touches. + + The walk is by position, because `watched` is built from the same packs in + the same order: a point's declaration belongs to that point's own count, and + an effect that slipped past one pack says nothing about another's. + + """ + uninterposed: dict[str, list[Watched]] = {} + at = 0 + for pack in packs: + declared = uninterposed_events(pack) + for index in range(len(pack.points)): + item = watched[at] + at += 1 + for event in declared.get(index, ()): + uninterposed.setdefault(event, []).append(item) + return uninterposed + + def _join_the_programs_threads() -> None: """Join every thread the interpreter itself would wait for, and no other.""" while True: @@ -834,6 +1289,7 @@ def _run_the_target( packs: Sequence[manifest.Pack], target: Sequence[str], watch: Watch, + correlation: str | None, ) -> int | None: """Hand the program over, in the mode the invocation asked for. @@ -855,6 +1311,10 @@ def _run_the_target( configured.profile, list(target), principal=configured.principal, + correlation=correlation, + # One recorded decision per effect is the proof, so nothing is + # answered from a grant: a repeated effect asks again. + hold_grants=False, out=sys.stdout, err=sys.stderr, starting=watch.arm, @@ -900,8 +1360,10 @@ def _one_page( ) -> Result[tuple[list[Mapping[str, object]], Mapping[str, object], int | None]]: """Re-open the connection, verify the far end again, and read one page. - Rule C4: the transport re-opens no address on a caller's behalf, and the - daemon closes the connection after some answers. Re-verifying before every + Rule C4: the transport re-opens no address on a caller's behalf, and a + daemon may close the connection after any answer — the one this client was + written against keeps it open after a document and closes it after a + stream, which is a behaviour and not a promise. Re-verifying before every read keeps that explicit — it is cheap over a Unix socket — and it puts the re-open inside the reader, so a socket that went away mid-run is a transport problem this client classifies rather than an exception raised @@ -914,8 +1376,57 @@ def _one_page( return Answered(pages.members(result.value, from_sequence), result.contract_generation) +class Judged(NamedTuple): + """The call judged last on one thread, kept for the one inner event it may raise next.""" + + #: The events its points name as their own call's implementation (`inner_events`). + inner: frozenset[str] + #: What its audit event carried. + arguments: tuple[object, ...] + + +def _argument_vector(value: object) -> tuple[str, ...] | None: + """An argument vector as the names it holds, or `None` for a shape that is not one. + + Only a list or a tuple is read. Iterating anything else could run the + program's own code, or use up an argument the call it belongs to was about + to consume — and a hook that did either would change the effect it watches. + """ + if isinstance(value, str | bytes | os.PathLike): + value = (value,) + if not isinstance(value, list | tuple): + return None + try: + return tuple(os.fsdecode(item) for item in value) + except (TypeError, ValueError): + return None + + +def the_same_act(judged: Judged, event: str, arguments: tuple[object, ...]) -> bool: + """Whether `event` is the implementation of the call just judged, not an act of its own. + + It is when the judged call's points name it as an inner event and it carries, + as its second argument, the argument vector the judged call's own event + carried as its second — the process a spawning call was asked to create, + against the process the lower-level call beneath it creates. Anything else + is another act, and is counted as the path around the pack it is. + + Which event counts as NEXT is the caller's rule, not this function's: + `_consulting` offers a judged call only to the very next event its thread + raises. + """ + if event not in judged.inner or len(judged.arguments) < 2 or len(arguments) < 2: + return False + spawned = _argument_vector(arguments[1]) + return spawned is not None and spawned == _argument_vector(judged.arguments[1]) + + def _consulting( - chain: Chain, by_event: Mapping[str, list[Watched]], watch: Watch + chain: Chain, + by_event: Mapping[str, list[Watched]], + uninterposed: Mapping[str, list[Watched]], + watch: Watch, + inner: Mapping[str, frozenset[str]], ) -> Callable[[str, tuple[object, ...]], None]: """The audit hook: for an event a pack named, ask the chain and act on the answer. @@ -932,9 +1443,36 @@ def _consulting( there; this function judges only what is left — and COUNTS what is neither judged nor the hand-off's, which is the one thing it may not drop (`Watch.whose` says what that shape is and what dropping it cost). + + **An event a pack named as a path it does NOT interpose is counted and let + through.** It is an effect of a kind the pack names, so a report that + dropped it published a point's verdict — earned by the calls that DID go + through the interposed attribute — as if it were the whole of the run. + Counting it makes the coverage of that claim visibly incomplete. Stopping + it would do something else entirely: `instrument run` does not interpose + that path, so a `verify` that blocked it would be stricter than the mode it + measures, and this software is a governance and observability layer and not + a confinement mechanism (article 2). The limit that instrumentation can miss + a call stays exactly as true and exactly as documented; what ends is the + silence about it. + + **Except the one its judged call raises itself.** A point may name, among + those events, the ones its own call raises on the way to the effect it was + asked for (`inner_events`). Such an event is not counted when it is the + VERY NEXT event the judging thread raises after the call it judged let + through, and `the_same_act` says it is that call's own. Next means next: a + thread's judged call is taken by whatever that thread raises after it, so + an inner event raised later, or a second one, is counted like any other. """ + # The call judged last on each thread, taken by the next event that thread + # raises — whatever that event is. + last_judged: dict[int, Judged] = {} + def consult(event: str, arguments: tuple[object, ...]) -> None: + # Empty unless a judged call is waiting for its next event, so every + # other event still costs one check here. + judged = last_judged.pop(threading.get_ident(), None) if last_judged else None if not watch.judging: # Either nothing has been handed over yet, or the hand-off is still # locating and reading the program. Both are the watch's question @@ -942,28 +1480,50 @@ def consult(event: str, arguments: tuple[object, ...]) -> None: watch.opening(arguments) return points = by_event.get(event) - if points is None: + missed = uninterposed.get(event) + if points is None and missed is None: return whose = watch.whose(arguments) if whose == THE_HAND_OFFS: return + if missed is not None and (judged is None or not the_same_act(judged, event, arguments)): + for item in missed: + item.not_judged(PATH_NOT_INTERPOSED) + sys.stderr.write(f"{NOT_JUDGED}: {event}: {PATH_NOT_INTERPOSED}\n") + if points is None: + return if whose == NOT_JUDGED: # No chain read and no raise: nothing was established about this # event, and inventing either answer would be a verdict nobody # measured. It is counted on every point the event would have been # judged against, which is what makes the run not a pass. for item in points: - item.unjudged += 1 + item.not_judged(A_START_FILE) return with watch.ours(): for item in points: _judge(chain, item, watch) + # Judged and let through: its own inner event, if it raises one, is the + # next event this thread raises. A judgement that stopped the call + # raised above, and a call that was stopped raises nothing further. + named = inner.get(event) + if named: + last_judged[threading.get_ident()] = Judged(named, arguments) return consult def _judge(chain: Chain, item: Watched, watch: Watch) -> None: - """One point, against one consultation of the chain.""" + """One point, against one consultation of the chain. + + Four endings, in the one order that cannot flatter the run. A record of + this run's supports the effect. A chain that answered nothing ends the run + with « could not read » and no finding. Evidence that could not be used, or + a walk that never reached the chain's end, leaves the effect NOT JUDGED — + aborted, counted, and never a finding, because a negative fact needs the + whole chain read. Only what is left is a finding, and it needs the walk to + have finished. + """ consultation = _consulted(chain, item.point.capability) if consultation.matched is not None: item.events += 1 @@ -977,17 +1537,46 @@ def _judge(chain: Chain, item: Watched, watch: Watch) -> None: f"{'no answer at all' if problem is None else problem.message}" ) if consultation.problem is not None: - # Some reads were answered and the last was not. The verdict below is - # made of what WAS read, and the failure is said beside it rather than + # Some reads were answered and the last was not. What is concluded below + # is made of what WAS read, and the failure is said beside it rather than # folded into it: a reader has to be able to see that the chain went # away while this run was concluding. sys.stderr.write( f"the chain was read and then stopped answering: {consultation.problem.message}\n" ) + unusable = consultation.unusable + if unusable is None and not consultation.established_absence: + unusable = WALK_INTERRUPTED + if unusable is not None: + _stopped_unjudged(item, unusable) + if consultation.foreign: + sys.stderr.write( + f"a record for {item.point.capability} exists in this scope and was recorded for " + f"another principal, so it is not a decision that preceded this effect\n" + ) item.refused = True raise RuntimeError(f"{UNGOVERNED} effect: {item.point.capability} ({item.point.audit_event})") +def _stopped_unjudged(item: Watched, reason: str) -> None: + """Abort an effect this run could not judge, count it, and make no finding. + + The abort is the same caution an ungoverned effect gets — an effect whose + governance could not be established is not one this harness may let through + — and the silence about the program is the difference: `refused` stays + false, the verdict stays about the events that WERE judged, and the count + is what refuses the run a pass (`verify._exit_for`). + """ + item.not_judged(reason) + sys.stderr.write( + f"{NOT_JUDGED}: {item.point.capability} ({item.point.audit_event}): {reason}\n" + ) + raise NotJudged( + f"this effect is not judged and was stopped: " + f"{item.point.capability} ({item.point.audit_event}): {reason}" + ) + + def _consulted(chain: Chain, capability: str) -> Consultation: """Poll the chain for a record this effect has not already been answered by. @@ -995,60 +1584,145 @@ def _consulted(chain: Chain, capability: str) -> Consultation: an effect the plane allowed may arrive after the effect was allowed to happen. - Every read that was not answered is remembered, and so is whether ANY was. - A consultation that read nothing at all across the whole patience has - established nothing about the program — not even an absence — which is why - that is a third outcome here and not a `False`. + Everything a walk could not establish is remembered across the patience, and + remembered SEPARATELY. A consultation that read nothing at all has + established nothing about the program; one whose every walk stopped half way + has established nothing either; one that saw a record it could not take has + established nothing about that record. None of the three is an absence, and + an absence is the only one of them that is a finding. """ deadline = time.monotonic() + CHAIN_WAIT read_something = False + established_absence = False + foreign = False problem: Problem | None = None + unusable: str | None = None with chain.lock: while True: - found, answered, failure = _matching(chain, capability) - read_something = read_something or answered - if failure is not None: - problem = failure - if found is not None: - chain.cursor = found - return Consultation(found, True, None) + walk = _matching(chain, capability) + read_something = read_something or walk.answered + established_absence = established_absence or walk.whole + foreign = foreign or walk.foreign + if walk.problem is not None: + problem = walk.problem + if walk.unusable is not None: + unusable = walk.unusable + if walk.matched is not None: + return Consultation(walk.matched, True, True, None, None, foreign) if time.monotonic() >= deadline: - return Consultation(None, read_something, problem) + return Consultation( + None, read_something, established_absence, problem, unusable, foreign + ) time.sleep(POLL_INTERVAL) -def _matching(chain: Chain, capability: str) -> tuple[int | None, bool, Problem | None]: - """One walk: the sequence matched, whether any page was answered, and the problem. - - Nothing is inferred from a read that was not answered — not here and not in - the poll above, which is what carries « no read succeeded » out to the - caller instead of letting it look like « no record exists ». +def _matching(chain: Chain, capability: str) -> Walk: + """One walk of the chain for a record of THIS run's effect of this kind. + + Nothing is inferred from a read that was not answered, and nothing is + inferred from a walk that did not finish: both are carried out to the caller + rather than left to look like « no record exists ». + + **What makes a record this effect's, and what this walk cannot establish.** + An entry is a candidate when it is an effect of the asked capability that + the plane allowed, at a position after the floor that no consultation has + already spent. A candidate becomes a SUPPORT only when it was recorded for + the account this process runs as, out of a range the chain's own + verification reports intact, and — where this run's own boundary is what + asked — on the one connection this run's records come from. Everything + those three refuse is said in one of two ways, and the difference matters: + a record of another principal is not this run's at all, so the absence of + one is still establishable and a finding may still be made; a record this + run cannot verify, or cannot tell from another execution's, leaves the + question open and no finding may be made from it. + + **What this walk still cannot check, said here because it is the gap.** It + does not compare the arguments of the call with the digest the record + carries, so two effects of one kind, one principal and one connection are + not told apart. Which arguments identify an effect is the pack's + declaration and the boundary's digest (article 11), and a pack publishes no + way to render them for anything but its own wrapper — so a verifier that + computed one would be a second policy, drifting from the first and + producing findings nobody made. `docs/PACKS.md` states the limit. """ answered = False - from_sequence = chain.cursor + 1 + unusable: str | None = None + foreign = False + from_sequence = chain.floor + 1 while True: result = reads.read(lambda at=from_sequence: _one_page(chain, at)) if not isinstance(result, Answered): - return None, answered, result.problem + return Walk(None, answered, False, result.problem, unusable, foreign) answered = True - entries, _, next_from = result.value + entries, served, next_from = result.value + intact = served.get("condition") == INTACT for entry in entries: sequence = entry.get("sequence") body = entry.get("body") if ( - isinstance(sequence, int) - and sequence > chain.cursor - and entry.get("kind") == EFFECT - and isinstance(body, Mapping) - and body.get("capability") == capability - and body.get("outcome") == ALLOW + not isinstance(sequence, int) + or sequence <= chain.floor + or sequence in chain.spent + or entry.get("kind") != EFFECT + or not isinstance(body, Mapping) + or body.get("capability") != capability + or body.get("outcome") != ALLOW ): - return sequence, True, None + continue + if not _recorded_for(chain.principals, entry): + # Somebody else's decision. Said, and stepped over: an absence + # of this run's own records is still an absence. + foreign = True + continue + if not intact: + unusable = unusable or EVIDENCE_NOT_INTACT + continue + if not _this_runs_correlation(chain, body): + unusable = unusable or ANOTHER_EXECUTIONS_RUN + continue + chain.spent.add(sequence) + return Walk(sequence, True, True, None, None, foreign) if next_from is None: - return None, answered, None + return Walk(None, answered, True, None, unusable, foreign) from_sequence = next_from +def _recorded_for(principals: frozenset[str], entry: Mapping[str, object]) -> bool: + """Whether the plane recorded this entry for the account this process runs as. + + An entry whose principal cannot be read is not read as this run's: an + identity this client could not establish is not an identity it may assume + (article 2). + """ + principal = entry.get("principal") + if not isinstance(principal, Mapping): + return False + named = principal.get("id") + return isinstance(named, str) and named in principals + + +def _this_runs_correlation(chain: Chain, body: Mapping[str, object]) -> bool: + """Whether this effect record carries the correlation this run's boundary stamped. + + Asked only where this run's own boundary is what asked. The token is this + harness's own, generated before the program started and handed to the + boundary, so a record is this run's when the plane recorded that same token + on it — which the plane does as `correlation_source: boundary_supplied`. + Nothing here trusts the thing it proves: the boundary does not choose the + token and the harness does not read it back from a record to learn it. A + record whose correlation is absent, another value, or unreadable is a record + this run cannot tell from another execution's, and the FIRST record is held + to the token exactly as every later one is. + + A run that stamped no token (`chain.correlation is None`) required none: that + is `--ungoverned`, where the chain alone answers and every record is another + execution's by construction. + """ + if not chain.one_execution or chain.correlation is None: + return True + return body.get("correlation") == chain.correlation + + def _write_report( configured: Configuration, packs: Sequence[manifest.Pack], diff --git a/src/sayfirst_cli/instrument/interpreter.py b/src/sayfirst_cli/instrument/interpreter.py new file mode 100644 index 0000000..6ab22a9 --- /dev/null +++ b/src/sayfirst_cli/instrument/interpreter.py @@ -0,0 +1,480 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A target that names its interpreter is run by that interpreter, and by no other. + +A governed program runs INSIDE the interpreter that installs the boundary: the +engine replaces attributes in a live process, so there is no governing from the +outside. When this command and the program share an environment that is the end +of it. When they do not — this command installed as a tool of its own, the +program living in its project's environment — a target spelled the way a person +runs it every day, `python app.py`, leaves two honest readings and one +dishonest one. Refusing the word is honest and useless. Dropping the word and +running `app.py` here is the dishonest one: the program would run under an +interpreter nobody named, without its own dependencies, while the command line +said otherwise (article 2). What this module does is the third: **the whole +command is handed to the interpreter that was named.** + +Handed over means replaced: this process becomes ` … sayfirst +instrument …` with the word removed, by `exec`, so the streams, the +signals and the exit status are the program's exactly as they are in-process, +and nothing here stays behind to translate them. Everything after that is the +ordinary path, in that interpreter: the same packs, the same profile, the same +launcher, the boundary in front of the program before its first import. + +**What is lent, and what is not.** That interpreter has to be able to import +this client, the boundary and the contract, and its environment may hold none +of them. They are lent by LOCATION and by NAME: a finder placed first on its +import machinery answers for exactly those three top-level names, each from the +directory this process imported it from, and answers for nothing else. This +environment's site directory is never put on that interpreter's path, so no +other package installed beside this command becomes importable by the program, +and none of the program's own can be shadowed. The three are pure Python with +no dependency outside themselves (articles 13 and 14 — the closure guard +measures it), which is what makes lending them by name sufficient. They answer +first on purpose: a program that holds its own copy of the boundary would +otherwise run this client against a contract of another version. + +**What decides that a word names an interpreter** is its spelling, as for a +pack: a first word whose last path component is `python`, `python3` or +`python3.N`. A bare one is looked up the way a shell would; one with a +separator is that file. A script that is really called `python3` is run as +`python ./python3`, like anywhere else. + +**An executable script hands over by its shebang.** A console-script agent is +started as `myagent`, not as `python -m myagent`, and its file begins +`#!/path/to/.venv/bin/python`. So when the target's first word is an executable +file whose shebang names a Python — including `#!/usr/bin/env python3` — that +Python runs it, the same hand-off a leading `python` word triggers, and for the +same reason: the boundary belongs in the interpreter the program actually runs +in. A shebang that names this same interpreter changes nothing; a non-executable +file, or a shebang that is a shell or a wrapper, is left to run as the source it +already was. + +**A runner in the first position is refused.** `uv run`, `poetry run` and their +kind start an interpreter this launcher has not chosen and cannot install the +boundary into ahead of time, so handing the runner over would govern nothing. +It is refused with the two spellings that do work: name the interpreter, or ask +the runner for it once, outside the governed command. + +**What it costs**, stated rather than discovered: the named interpreter is +asked for its version first (one short process), because a Python older than +this client's floor cannot even parse it and the refusal should be a sentence +here, not a syntax error there; and interpreter options (`-u`, `-X …`) are not +carried — the target grammar stays « a script, or `-m` and a module », and an +option in that place is refused as the unknown script it would be. +""" + +from __future__ import annotations + +import importlib +import importlib.metadata +import json +import os +import re +import shutil +import subprocess +import sys +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Final + +from . import launch + +#: The oldest interpreter that can hold this client. `pyproject.toml` states the +#: same floor as `requires-python`; this is the copy a running process can read, +#: and `tests/test_interpreter_target.py` holds the two together. +MINIMUM: Final[tuple[int, int]] = (3, 12) + +#: The first interpreter too new for what is lent: every lent distribution +#: declares `requires-python` below it, and a package installed on an +#: interpreter it does not declare is one nobody measured there. Held to +#: `pyproject.toml` the same way as the floor. +BEYOND: Final[tuple[int, int]] = (3, 15) + +#: What is lent to a named interpreter: this client and the two distributions it +#: depends on, each as the package that is imported and the distribution whose +#: metadata says which release it is. Nothing else is ever lent. +#: +#: The metadata is lent because the contract reads its OWN release from it when +#: it is imported (the deprecation window of a generation is dated by release), +#: and an installed package whose metadata cannot be found does not import at +#: all. It is lent the way the packages are: by name, from one location, and a +#: listing of « every distribution installed » is never answered from here. +LENT: Final[dict[str, str]] = { + "sayfirst_cli": "sayfirst-cli", + "sayfirst_boundary": "sayfirst-boundary", + "sayfirst_contract": "sayfirst-contract", +} + +#: The two members of what is handed to the bootstrap. +_PACKAGES: Final[str] = "packages" +_DISTRIBUTIONS: Final[str] = "distributions" + +#: What a target may be, said once for the two verbs that take one. A help text is +#: a claim about what is read, so the interpreter word is in it. +TARGET_HELP: Final[str] = ( + "after `--`: SCRIPT [args] or -m MODULE [args], run inside this command's own " + "interpreter; led by an interpreter's name (python app.py, .venv/bin/python -m pkg), " + "or an executable script whose shebang names one (./myagent), it is run by that " + "interpreter instead; a runner (uv run, poetry run) is refused" +) + +#: How long a named interpreter has to say which version it is. +PROBE_SECONDS: Final[float] = 20.0 + +_INTERPRETER_WORD: Final = re.compile(r"^python(3(\.\d+)?)?$") + +#: The attribute the bootstrap's finder carries, which is how a process learns +#: that it is running on lent packages and has to lend them on in turn. +_MARK: Final[str] = "sayfirst_lent_locations" + +#: Set in the environment of the interpreter a target was handed to, so that the +#: `sayfirst instrument` this launcher runs THERE does not choose an interpreter +#: all over again. Without it the handed-over command reads its target's first +#: word a second time: an executable whose shebang names yet another interpreter +#: is exec'd away from the one just chosen, and an interpreter that resolves to a +#: forwarding shim (its path never equal to the real `sys.executable`) is handed +#: over on every pass and loops. The mark is READ ONCE and removed, so a program +#: that itself runs `sayfirst instrument` is unaffected, and it is set only for +#: the process being exec'd — never for the followed children of `follow.py`. +CHOSEN_VARIABLE: Final[str] = "SAYFIRST_INTERPRETER_CHOSEN" + +#: What runs first in the named interpreter. It receives the lent locations as +#: its first argument, what to run as its second, and the arguments after that. +#: +#: The interpreter is started with `-P`, so nothing of the working directory is +#: on the import path while this client imports itself; a placeholder then takes +#: the head of the path, because the launcher REPLACES the head with what the +#: interpreter would have put there for the program (`launch._hand_off`) and +#: must find something there that is its to replace. The placeholder names no +#: directory, so nothing can be imported from it in the meantime. +_BOOTSTRAP: Final[str] = f"""\ +import json, sys +from importlib.machinery import PathFinder + +class _Lent: + {_MARK} = json.loads(sys.argv[1]) + + @classmethod + def find_spec(cls, name, path=None, target=None): + where = cls.{_MARK}[{_PACKAGES!r}].get(name) if path is None else None + return None if where is None else PathFinder.find_spec(name, [where]) + + @classmethod + def find_distributions(cls, context=None): + from importlib.metadata import DistributionFinder, MetadataPathFinder + name = getattr(context, "name", None) + where = cls.{_MARK}[{_DISTRIBUTIONS!r}].get(str(name).replace("_", "-").lower()) + if name is None or where is None: + return iter(()) + return MetadataPathFinder.find_distributions( + DistributionFinder.Context(name=name, path=[where]) + ) + +sys.meta_path.insert(0, _Lent) +sys.path.insert(0, {launch.HANDED_OVER_HEAD!r}) +kind, arguments = sys.argv[2], sys.argv[3:] +if kind == "command": + import os + os.environ[{CHOSEN_VARIABLE!r}] = "1" + from sayfirst_cli.main import main + raise SystemExit(main(arguments)) +import runpy +sys.argv = [kind, *arguments] +runpy.run_module(kind, run_name="__main__", alter_sys=True) +""" + +#: The second argument that asks for this client's own command line. +_COMMAND: Final[str] = "command" + +#: Runners that start an interpreter of their OWN, in an environment they pick. +#: A program named through one of these cannot be governed by handing IT over — +#: the boundary has to be installed inside the interpreter the program runs in, +#: and a runner has not chosen that interpreter yet. So a runner in the first +#: position is refused with the spelling that does work: name the interpreter. +RUNNERS: Final[frozenset[str]] = frozenset( + {"uv", "poetry", "pdm", "hatch", "pipenv", "rye", "pixi", "tox", "nox"} +) + +#: How many bytes of a file are read to look for a shebang. A shebang the kernel +#: honours is on the first line; a line longer than this is not one it would run. +_SHEBANG_BYTES: Final[int] = 512 + + +def named_in(target: Sequence[str]) -> str | None: + """The first word of a target if it is spelled as an interpreter, else `None`.""" + if not target: + return None + return target[0] if _INTERPRETER_WORD.fullmatch(Path(target[0]).name) else None + + +def _refuse_a_runner(target: Sequence[str]) -> None: + """A runner in the first position names no interpreter this launcher can govern in. + + `uv run app.py`, `poetry run app.py` and their kind start a fresh interpreter + the runner selects, and the boundary cannot be in it before it starts. Rather + than hand the runner over — which would run this whole command inside the + runner and govern nothing — it is refused with the two spellings that work: + name the interpreter directly, or ask the runner for it once, outside. + """ + if len(target) >= 2 and Path(target[0]).name in RUNNERS and target[1] == "run": + runner = Path(target[0]).name + raise launch.LaunchMisuse( + f"this launcher cannot govern a program started by {runner!r}: a runner starts an " + f"interpreter of its own, and the boundary has to be installed inside the interpreter " + f"the program runs in. Name that interpreter instead — `.venv/bin/python app.py`, or " + f"`$({runner} run which python) app.py`" + ) + + +def shebang_interpreter(script: str) -> str | None: + """The Python an executable script's shebang names, as an absolute path, or None. + + A shebang matters only for a file the kernel would execute directly, so this + is consulted only for an executable file. `#!/usr/bin/env python3.12` and + `#!/path/to/python` are both read; a shebang that names anything but a Python + — a shell, `env -S …`, a wrapper — answers None, and the file is left to run + as the Python source a target without an interpreter word already is. One + that names a Python which is not there is refused: the system would refuse + to run the script, and another Python running it is nobody's program. When + the Python it names is THIS interpreter, the caller runs the script in + process exactly as before; the hand-off happens only for another one. + """ + path = Path(script) + if not (path.is_file() and os.access(path, os.X_OK)): + return None + try: + with open(path, "rb") as opened: + first = opened.read(_SHEBANG_BYTES).split(b"\n", 1)[0] + except OSError: + return None + if not first.startswith(b"#!"): + return None + try: + words = first[2:].decode("utf-8").split() + except UnicodeDecodeError: + return None + if not words: + return None + # `#!/usr/bin/env python3` names the interpreter in the second word; a bare + # `env` with no argument, or `env -S …`, names nothing this reads. + candidate = words[1] if Path(words[0]).name == "env" and len(words) >= 2 else words[0] + if candidate.startswith("-") or not _INTERPRETER_WORD.fullmatch(Path(candidate).name): + return None + resolved = candidate if os.path.isabs(candidate) and os.path.exists(candidate) else None + resolved = resolved or shutil.which(candidate) + if not resolved: + # A Python the kernel would not find either: it refuses the script, and + # running it under this command's own interpreter instead would be the + # dishonest reading this module exists to refuse. + raise launch.LaunchMisuse( + f"{script}: its first line names {candidate!r}, and there is no such interpreter " + f"here — the system would refuse to run it, so this launcher does too rather than " + f"run it under another Python" + ) + return os.path.abspath(resolved) + + +def selection(target: Sequence[str]) -> tuple[str, list[str]] | None: + """Which interpreter runs this target, and the program to run there, or None. + + None means « run it in this command's own interpreter », which is the + unchanged path for a script, a `-m` module, and an executable whose shebang + is not a Python. Otherwise the pair is the interpreter and the program handed + to it: for an interpreter word the program is everything after the word; for + an executable script the program is the whole target, because the script + itself is what that interpreter runs. A runner in the first position raises + rather than returning, for the reason `_refuse_a_runner` gives. + """ + if not target or target[0] == launch.MODULE_FORM: + return None + word = named_in(target) + if word is not None: + return locate(word), list(target[1:]) + _refuse_a_runner(target) + shebang = shebang_interpreter(target[0]) + if shebang is not None: + return shebang, list(target) + return None + + +def locate(word: str) -> str: + """The interpreter a word names, as an absolute path, or the misuse that it names none.""" + found = shutil.which(word) + if found is None: + 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" + ) + return os.path.abspath(found) + + +def is_this_one(executable: str) -> bool: + """Whether an interpreter is the one already running this command. + + Compared as names and never resolved: an environment's `python` is a link + to the interpreter it was made from, so two environments resolve to one + file while being two different answers to « which packages are there ». + Saying no to a second spelling of this same environment costs one `exec` + into the environment we are already in, and changes nothing else. + """ + return os.path.abspath(executable) == os.path.abspath(sys.executable) + + +def lent_locations() -> dict[str, dict[str, str]]: + """Where this process holds what it lends: each package, and each distribution's metadata. + + The two are asked for separately because they are not always one directory: + a distribution installed for development keeps its metadata in the + environment and its package in a source tree. + + A process that is itself running on lent packages lends the same locations + on, rather than working them out again from modules a finder placed. + """ + inherited = _inherited() + if inherited is not None: + return {member: dict(found) for member, found in inherited.items()} + packages: dict[str, str] = {} + distributions: dict[str, str] = {} + for package, distribution in LENT.items(): + origin = getattr(importlib.import_module(package), "__file__", None) + try: + metadata = importlib.metadata.distribution(distribution).locate_file("") + except importlib.metadata.PackageNotFoundError: + metadata = None + if not isinstance(origin, str) or metadata is None: + raise launch.LaunchMisuse( + f"this launcher cannot lend {distribution!r} to another interpreter: it is " + f"not installed from a directory here, so there is no location to lend" + ) + packages[package] = str(Path(origin).resolve().parent.parent) + distributions[distribution] = str(Path(str(metadata)).resolve()) + return {_PACKAGES: packages, _DISTRIBUTIONS: distributions} + + +def _inherited() -> dict[str, dict[str, str]] | None: + for finder in sys.meta_path: + lent = getattr(finder, _MARK, None) + if isinstance(lent, dict): + return lent + return None + + +#: What the version probe prints around the two numbers, so its own line can be +#: found however much an interpreter's start-up wrote before it. A marker rather +#: than « the whole of stdout is the version » — a target whose `sitecustomize` +#: announces itself on start-up is a usable interpreter, not a malformed version. +_VERSION_MARK: Final[str] = "sayfirst-version:" + + +def version_of(executable: str) -> tuple[int, int]: + """The version an interpreter says it is, or the misuse that it would not say. + + Probed with `-I`, so the interpreter runs isolated — no `site`, no + `sitecustomize`, no `PYTHONSTARTUP`, nothing of the environment — and cannot + print anything of its own around the answer. The answer is still found by its + marker rather than by reading the whole of stdout, because `-I` suppresses + the ordinary start-up files but not, say, a `usercustomize` a build wrote + into the standard library, and a version query has no business being that + fragile. `-I` here changes nothing about how the program itself later starts + — that is `_hand_off`'s doing, under the interpreter's normal start-up. + """ + try: + finished = subprocess.run( + [ + executable, + "-I", + "-c", + f"import sys; print('{_VERSION_MARK}', sys.version_info[0], sys.version_info[1])", + ], + capture_output=True, + text=True, + timeout=PROBE_SECONDS, + check=False, + ) + line = next(row for row in reversed(finished.stdout.splitlines()) if _VERSION_MARK in row) + _, major, minor = line.rsplit(maxsplit=2) + return int(major), int(minor) + except (OSError, ValueError, StopIteration, subprocess.SubprocessError) as unusable: + raise launch.LaunchMisuse( + f"this launcher could not ask {executable!r} which Python it is: {unusable}" + ) from unusable + + +def holds_this_client(executable: str) -> bool: + """Whether an interpreter imports this client and the two it depends on by itself. + + What `--follow-children` needs of the interpreter a program's children run: + the packages lent to the program's own process live in that process's + finder, and a child is a fresh process with none. Asked with `-P`, so the + answer is the interpreter's and not the working directory's. + """ + try: + finished = subprocess.run( + [executable, "-P", "-c", "import " + ", ".join(sorted(LENT))], + capture_output=True, + timeout=PROBE_SECONDS, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return False + return finished.returncode == 0 + + +def command_in(executable: str, kind: str, *arguments: str) -> list[str]: + """The command line that runs something of this client inside another interpreter.""" + return [executable, "-P", "-c", _BOOTSTRAP, json.dumps(lent_locations()), kind, *arguments] + + +def module_command(module: str, *arguments: str) -> list[str]: + """How this process starts one of this client's own modules in a second process. + + `sys.executable -m module` wherever this client is installed in the running + interpreter. In an interpreter that was LENT it, that spelling finds + nothing — the second process starts with no finder — so the lending is + handed on with the command. + """ + if _inherited() is None: + return [sys.executable, "-m", module, *arguments] + return command_in(sys.executable, module, *arguments) + + +def hand_the_command_to( + executable: str, + arguments: Sequence[str], + *, + follow_children: bool = False, + execv: Callable[[str, list[str]], object] = os.execv, +) -> None: + """Become `executable`, running this client's own command line there. + + `arguments` is everything after `sayfirst`. On success this does not + return: the process is replaced, and the code a shell reads is whatever that + command line answers. It raises `LaunchMisuse` — before anything is handed + over — for an interpreter this client cannot run in, and, with + `follow_children`, for one whose children could not install the boundary. + """ + found = version_of(executable) + if found < MINIMUM: + raise launch.LaunchMisuse( + f"this launcher cannot run the program under {executable!r}: it is Python " + f"{found[0]}.{found[1]}, and the boundary has to be installed inside the " + f"interpreter the program runs in, which takes Python " + f"{MINIMUM[0]}.{MINIMUM[1]} or later" + ) + if found >= BEYOND: + raise launch.LaunchMisuse( + f"this launcher cannot run the program under {executable!r}: it is Python " + f"{found[0]}.{found[1]}, and what it would lend that interpreter declares Python " + f"before {BEYOND[0]}.{BEYOND[1]}" + ) + if follow_children and not holds_this_client(executable): + raise launch.LaunchMisuse( + f"--follow-children cannot follow the program's children under {executable!r}: " + f"this client is not installed there, and what is lent to the program's own " + f"process is not lent to a child it starts, so every Python child would fail at " + f"start-up. Install this client in that interpreter, or run without the flag" + ) + command = command_in(executable, _COMMAND, *arguments) + sys.stdout.flush() + sys.stderr.flush() + execv(executable, command) diff --git a/src/sayfirst_cli/instrument/launch.py b/src/sayfirst_cli/instrument/launch.py index 3a4502f..1aa36a8 100644 --- a/src/sayfirst_cli/instrument/launch.py +++ b/src/sayfirst_cli/instrument/launch.py @@ -37,11 +37,13 @@ from __future__ import annotations +import atexit import importlib.util import os import pwd import runpy import sys +import threading from collections.abc import Callable, Sequence from dataclasses import dataclass from importlib.machinery import ModuleSpec @@ -52,6 +54,7 @@ from sayfirst_contract.binding.http_unix_socket.client import SocketClient from sayfirst_contract.transport.socket_client import SocketProfile, expected_principal_uid +from . import follow from .engine import Engine, EngineMisuse from .manifest import Pack @@ -63,6 +66,12 @@ #: interpreter rather than invented, so that one form is typed one way. MODULE_FORM: Final[str] = "-m" +#: The head of the import path in an interpreter this command was handed to: +#: the hand-over's bootstrap puts it there, and `_hand_off` replaces it with +#: what that interpreter would have put there for the program. It names no +#: directory, so nothing can be imported from it in the meantime. +HANDED_OVER_HEAD: Final[str] = "" + #: What a caller is told at the last instant before the hand-off: which files #: are the program's OWN, which the hand-off reads to reach them, and which of #: the second the import system derives further names from. Nothing else is @@ -115,6 +124,9 @@ def run( target: Sequence[str], *, principal: str | None = None, + correlation: str | None = None, + follow_children: bool = False, + hold_grants: bool = True, out: TextIO, err: TextIO, starting: Starting | None = None, @@ -137,6 +149,12 @@ def run( after everything this launcher does for itself — the engine's load of each pack's execution module included. A caller that has to tell its own work from the program's cannot draw that line from outside. + + `hold_grants=False` asks for every effect and holds no grant. The verifier + runs this way: its proof is one recorded decision for each effect it saw, + and a grant hit — an identical effect answered by an earlier allow, which is + what `run` does (article 10) — records nothing, so a repeated effect would + read as ungoverned. """ program = _the_program(list(target)) client = SocketClient( @@ -144,7 +162,15 @@ def run( expected_uid=expected_principal_uid(profile), timeout=ASK_TIMEOUT, ) - boundary = Boundary(client=client, principal_reference=principal or _this_account()) + boundary = Boundary( + client=client, + principal_reference=principal or _this_account(), + # Set only by the verifier, which stamps one run and matches records on + # it (`harness.py`). The launcher's own governed mode leaves it None: a + # run is not a proof, and nothing reads a correlation back from it. + correlation=correlation, + hold_grants=hold_grants, + ) try: # The engine is deliberately not kept: the interposition's scope is this # process, and `uninstall` exists for a caller that wants it back @@ -154,9 +180,44 @@ def run( # The pack was designated on the command line, so a pack the engine # refuses is this invocation's mistake and not the program's failure. raise LaunchMisuse(str(refused)) from refused + if follow_children: + # Arranged in THIS process's environment, right before the program + # starts, so every Python child the program spawns installs the same + # boundary before its own code runs (`follow.py`). Set here rather than + # earlier so it is not inherited by anything this launcher spawns for + # itself; the program is the only thing started after this. + os.environ.update( + follow.environment_for( + os.environ, + list(packs), + socket=profile.socket_path, + scope=profile.scope, + mode=profile.mode, + daemon_user=profile.daemon_user, + principal=principal, + ) + ) return _hand_off(program, err, starting) +def _the_head_for(program: Program) -> list[str]: + """What the interpreter would have put at the head of the import path for the program. + + Its directory — for `-m`, the working directory — unless the person asked + for a safe path (`-P`, `PYTHONSAFEPATH`), which puts nothing there. In this + command's own interpreter the head is then an entry of the person's own, + and it is kept: replacing it took a real directory off the path and the + program's import of it failed. Handed over, the flag is the hand-over's own + `-P`, the head is its placeholder, and the person's setting is read off the + environment that interpreter was given. + """ + if sys.path and sys.path[0] == HANDED_OVER_HEAD: + return [] if os.environ.get("PYTHONSAFEPATH") else [program.first_on_the_path] + if sys.flags.safe_path: + return sys.path[:1] + return [program.first_on_the_path] + + def hand_over(target: Sequence[str], *, err: TextIO, starting: Starting | None = None) -> int: """Run the program with nothing in front of it, as the interpreter would. @@ -172,14 +233,26 @@ def hand_over(target: Sequence[str], *, err: TextIO, starting: Starting | None = return _hand_off(_the_program(list(target)), err, starting) -def _this_account() -> str: +def this_account() -> str: """Who this process is, in the one spelling the boundary uses for a person. Never sent as a credential: the daemon establishes identity from the peer of the connection (article 6). This is what the holder compares a grant's own - condition against, locally. + condition against, locally. Public because a followed child builds its own + boundary and needs the same spelling this one uses (`follow.py`). + + A uid no account database names — a container started under an arbitrary + uid — is spelled by its number, which is how the daemon names such a peer. """ - return f"user:{pwd.getpwuid(os.geteuid()).pw_name}" + uid = os.geteuid() + try: + return f"user:{pwd.getpwuid(uid).pw_name}" + except KeyError: + return f"user:{uid}" + + +#: Kept for readers inside this module. +_this_account = this_account def _the_program(target: list[str]) -> Program: @@ -218,8 +291,33 @@ def _hand_off(program: Program, err: TextIO, starting: Starting | None = None) - the program for the form it was named in — the script's own directory, or the working directory for `-m` — because a governed program is the same program, and one that cannot import the module beside it is not being - governed, it is being broken. Both are put back afterwards, so nothing here - outlives the run. + governed, it is being broken. + + **They are put back when the PROGRAM ends, which is not when its main + module returns.** A handler it registered to run at exit, and a thread it + started and did not join, are both the program's own code and both run + afterwards; under verification later still, because the harness runs them + itself rather than leaving them to the interpreter. Measured as the defect: + a handler reading `sys.argv` was handed the `sayfirst` command line instead + of the program's arguments, and `import` of the module beside the program + raised `ModuleNotFoundError` — an ordinary program changed by being + governed, over something it never asked the boundary about. + + So the restore is registered with `atexit` BEFORE the program starts, and + `atexit` runs its register last in, first out: every handler the program + registers afterwards runs ahead of it, and the interpreter runs the + program's surviving threads out before any of them. It is registered + rather than called at the end, so it happens however the program ended — + returning, raising, or `SystemExit` — and on the verifier's path as well, + where `atexit._run_exitfuncs` reaches it before the findings are written. + + A program that leaves NOTHING behind is finished when its main module + returns, and its state is put back there, in the `finally` — the same + instant as before. That is not a hedge, it is the same rule read at the + only moment it can be read: nothing of the program is left to see it. + What « nothing » means is asked of the interpreter — no thread of the + program's still running, nothing added to the exit register — and a + program that leaves either keeps its state until both are done with. `starting` is called after the name has been resolved and the import path arranged, and immediately before the interpreter is handed the program. It @@ -242,10 +340,14 @@ def _hand_off(program: Program, err: TextIO, starting: Starting | None = None) - The number is why the caches are named apart from the rest; the descriptor is why the moment is reported as well as the paths. """ - restored_argv, restored_path = list(sys.argv), list(sys.path) + restore = _putting_back(list(sys.argv), list(sys.path)) + # Before the program starts, so the register's last-in-first-out order puts + # this after everything the program adds to it. + atexit.register(restore) + registered, running = atexit._ncallbacks(), _the_threads_running_now() # Assigned as a slice, so a path with nothing on it is added to rather than # subscripted. Every import this launcher needs is already done. - sys.path[:1] = [program.first_on_the_path] + sys.path[:1] = _the_head_for(program) try: if program.module is not None: origins = _refuse_a_name_that_names_no_module(program.module, starting) @@ -261,11 +363,72 @@ def _hand_off(program: Program, err: TextIO, starting: Starting | None = None) - except SystemExit as ending: return _ending(ending.code, err) finally: - sys.argv = restored_argv - sys.path[:] = restored_path + if not _the_program_outlives_its_main_module(registered, running): + atexit.unregister(restore) + restore() return 0 +def _putting_back(argv: list[str], path: list[str]) -> Callable[[], None]: + """The launcher's own arguments and import path, restored once and once only. + + Once, because it can be reached twice: the verifier's harness runs the exit + register itself and the interpreter runs what is left of it afterwards, and + two hand-offs in one process leave two of these in the register. Each one + puts back what IT saw, and the innermost runs first, so a process ends with + the state it started with however many programs it ran. + + `sys.argv` is rebound and `sys.path` is assigned into, exactly as they were + taken: something else may be holding the list object the path came in. + """ + put_back = False + + def restore() -> None: + nonlocal put_back + if put_back: + return + put_back = True + sys.argv = list(argv) + sys.path[:] = list(path) + + return restore + + +def _the_threads_running_now() -> frozenset[int]: + """Every thread alive at this instant, by the identity `threading` gives it.""" + return frozenset(thread.ident for thread in threading.enumerate() if thread.ident is not None) + + +def _the_program_outlives_its_main_module(registered: int, running: frozenset[int]) -> bool: + """Whether anything of the program is still to come, asked of the interpreter. + + Two things outlive a main module and both are the program's own code: a + handler it registered to run at exit, and a thread it started that the + interpreter will wait for. Neither is asked about by name — the exit + register cannot be read and a thread does not say who started it — so each + is read as a CHANGE since the hand-off began: the register grew, or a + non-daemon thread is running that was not running before. + + A daemon thread is not one of them. The interpreter does not wait for one + either; it ends it at shutdown, so holding the program's state for one + would be holding it for something that may never finish. + + It errs towards saying no, and that is the direction to err in here: a no + puts the launcher's state back at the instant this function is asked, which + is where it was always put back before any of this existed. + """ + if atexit._ncallbacks() > registered: + return True + current = threading.current_thread() + return any( + thread is not current + and not thread.daemon + and thread.is_alive() + and thread.ident not in running + for thread in threading.enumerate() + ) + + def _starting( starting: Starting | None, starts: Sequence[str], diff --git a/src/sayfirst_cli/instrument/verify.py b/src/sayfirst_cli/instrument/verify.py index 318082a..30f5eae 100644 --- a/src/sayfirst_cli/instrument/verify.py +++ b/src/sayfirst_cli/instrument/verify.py @@ -52,7 +52,6 @@ import argparse import json import subprocess -import sys import tempfile from collections.abc import Mapping, Sequence from pathlib import Path @@ -60,6 +59,7 @@ from sayfirst_contract.generation import CONTRACT_GENERATION from sayfirst_contract.problems import ( + REFUSED, Problem, ProblemCode, classes_by_code, @@ -73,7 +73,7 @@ ) from .. import exit_codes, reads, render -from . import harness, manifest +from . import designation, harness, interpreter, manifest #: The module the harness is run as. Named here so that the one place a second #: interpreter is started names what it starts, and so a test can point it at @@ -158,11 +158,20 @@ class Ending(NamedTuple): `code` is the transport's own classification of the chain read that did not answer, present only where there was one and the registry carries it. + `refused` says the control plane refused that read, which is 3 and not 4. """ reason: str detail: str code: ProblemCode | None + refused: bool = False + + +#: The two endings that are a chain read the contract classified. Only these +#: carry a class; every other ending is the harness's own and keeps its code. +_CLASSIFIED_READS: Final[frozenset[str]] = frozenset( + {harness.CHAIN_UNREADABLE_BEFORE, harness.CHAIN_UNREADABLE_DURING} +) def add_arguments(parser: argparse.ArgumentParser) -> None: @@ -171,11 +180,11 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: "--pack", action="append", required=True, - metavar="DIR", - help="a pack directory; repeat the option for each pack", + metavar="PACK", + help=designation.HELP, ) parser.add_argument("--scope", required=True, help="the scope the questions are asked in") - parser.add_argument("--socket", required=True, help="the path of the daemon's socket") + reads.add_socket_argument(parser) parser.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER) parser.add_argument( "--daemon-user", @@ -198,12 +207,13 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: "target", nargs="*", metavar="TARGET", - help="after `--`: either -m MODULE [args] or SCRIPT [args]", + help=interpreter.TARGET_HELP, ) def run(arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO) -> int: """Prove the program, and answer with the code the verdicts imply.""" + directories: list[str] = [] for named in arguments.pack: try: # Read and thrown away. The harness reads every pack again, because @@ -211,14 +221,25 @@ def run(arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO) -> int: # makes a pack that will not read the misuse it is, rather than a # verification that could not be obtained for a reason the caller # would have to go and guess at. - manifest.read_pack(Path(named)) + # + # What is KEPT is the directory the designation named. The harness + # is handed directories and never designations: a name is resolved + # once, by the process a person typed it to, so the pack that is + # verified is the pack that was validated here. + directory = designation.directory_of(named) + manifest.read_pack(directory) except manifest.PackInvalid as invalid: - # The path as it was TYPED, for the reason `commands.py` gives. + # The designation as it was TYPED, for the reason `commands.py` gives. stderr.write(f"{named}: {invalid}\n") return exit_codes.EXIT_MISUSE + directories.append(str(directory)) try: + # Resolved HERE, once, and handed to the harness as a path: the harness + # shares no state with this process, and two processes each working out + # a default is two chances to look in two places. + address = reads.address_of(arguments) SocketProfile( - arguments.socket, + address.path, mode=arguments.mode, daemon_user=arguments.daemon_user, scope=arguments.scope, @@ -236,8 +257,8 @@ def run(arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO) -> int: configuration.write_text( json.dumps( { - "packs": list(arguments.pack), - "socket": arguments.socket, + "packs": directories, + "socket": address.path, "mode": arguments.mode, "daemon_user": arguments.daemon_user, "scope": arguments.scope, @@ -255,6 +276,9 @@ def run(arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO) -> int: _harness_output(_harness(configuration, list(arguments.target)), stderr) document = _findings(report) if document is None: + # An address nobody typed is said on the way to saying that nothing + # was concluded, for the reason `reads.say_where_it_looked` gives. + reads.say_where_it_looked(address, arguments, stderr) return _no_answer(outcome_file, arguments, stdout, stderr) return _rendered(document, arguments, stdout, stderr) @@ -278,7 +302,7 @@ def _no_answer( before it began » for a verification that had in fact run. Only `INVOCATION_REFUSED` is the invocation's mistake, and only it answers 64, which is the code `instrument run` gives the very same mistake; `_EXIT_FOR` - below says what the other endings answer, and why only one of them is not 4. + below says what the other endings answer, and why. """ said = _outcome(outcome_file) if said is None: @@ -305,18 +329,24 @@ def _no_answer( arguments, stdout, stderr, - _EXIT_FOR.get(said.reason, exit_codes.EXIT_COULD_NOT_ASK), + exit_codes.EXIT_REFUSED + if said.refused + else _EXIT_FOR.get(said.reason, exit_codes.EXIT_COULD_NOT_ASK), ) -#: The code a shell reads for the two endings that are not « the verification -#: could not be obtained ». An invocation this client refused is the 64 -#: `instrument run` gives the same mistake; findings that exist and will not -#: read are the 7 `_unreadable` answers for the very same sentence, and the one -#: `docs/PACKS.md` states — with the outcome file the client now KNOWS the -#: findings exist, which is what 7 is for. Every other ending is 4. +#: The code a shell reads for the endings that are not « the chain could not be +#: read ». An invocation this client refused is the 64 `instrument run` gives +#: the same mistake. Findings that exist and will not read are the 7 +#: `_unreadable` answers for the very same sentence — with the outcome file the +#: client KNOWS the findings exist, which is what 7 is for. A gate that never +#: opened is 7 too: the chain was read, the plane was asked, and what could not +#: be done was the watching, which is a check that could not conclude and not a +#: control plane that could not be asked. The two chain endings are 4, or 3 +#: when the plane refused the read (`Ending.refused`). _EXIT_FOR: Final[dict[str, int]] = { harness.INVOCATION_REFUSED: exit_codes.EXIT_MISUSE, + harness.GATE_NEVER_OPENED: exit_codes.EXIT_COULD_NOT_CHECK, harness.REPORTED: exit_codes.EXIT_COULD_NOT_CHECK, } @@ -339,9 +369,14 @@ def _outcome(path: Path) -> Ending | None: detail = document.get("detail") if named not in harness.OUTCOMES or not isinstance(named, str): return None - return Ending( - named, detail if isinstance(detail, str) else "", _carried(document.get("problem_code")) + code = _carried(document.get("problem_code")) + # Refused only on a read the contract classified, with a code the registry + # knows: an absent or unknown class, or a class beside the fallback code, + # is the fallback's « could not ask » and never the more precise answer. + refused = ( + named in _CLASSIFIED_READS and code is not None and document.get("problem_class") == REFUSED ) + return Ending(named, detail if isinstance(detail, str) else "", code, refused) def _carried(value: object) -> ProblemCode | None: @@ -367,16 +402,33 @@ def _harness(configuration: Path, target: Sequence[str]) -> subprocess.Completed `sys.executable` rather than a name looked up on the path: the harness is this distribution's own module, and the interpreter that can import it is - the one running this command. + the one running this command. Where this command is itself running in an + interpreter a target NAMED — lent this client rather than holding it — the + same lending goes on to the harness, which is `interpreter.module_command`'s + one job: a second process starts with no finder, and would otherwise end on + an import error having said nothing. A timeout is not a verdict. It comes back as a run that wrote no findings, which this command reports as a verification that did not conclude. + + **The two streams are read as bytes and decoded with replacement, and this + is not laxity about the report.** The target is arbitrary Python: one byte + no locale decodes — a `0xff` from a library's diagnostics — made + `subprocess.run(text=True)` raise `UnicodeDecodeError` HERE, in the parent, + before `_findings` had opened the report file. A verification that had run + to a conclusion was discarded by output that says nothing about governance, + and the command crashed instead of answering either its findings or an + explicit inability to verify. Nothing of the report travels on these + streams — the harness writes it to a file of its own (`_write_report`) and + `_findings` reads THAT, strictly — so replacement here reaches diagnostics + only, and a report that will not decode stays what it was: no findings, and + never a verdict. Decoded by `_text`, which the timeout path has always + used: one rule for what a program printed, not a second one. """ try: - return subprocess.run( - [sys.executable, "-m", HARNESS_MODULE, str(configuration), "--", *target], + finished = subprocess.run( + interpreter.module_command(HARNESS_MODULE, str(configuration), "--", *target), capture_output=True, - text=True, timeout=HARNESS_TIMEOUT, check=False, ) @@ -388,10 +440,21 @@ def _harness(configuration: Path, target: Sequence[str]) -> subprocess.Completed stderr=_text(expired.stderr) + f"the program had not ended after {HARNESS_TIMEOUT:g} seconds\n", ) + return subprocess.CompletedProcess( + finished.args, + returncode=finished.returncode, + stdout=_text(finished.stdout), + stderr=_text(finished.stderr), + ) def _text(value: object) -> str: - """What a timeout kept of a stream, which may be bytes, text or nothing.""" + """What a run or a timeout kept of a stream, which may be bytes, text or nothing. + + Replacement rather than strict: these are the program's own two streams, + and a program is free to print bytes. Applied to diagnostics and to nothing + else — see `_harness` and `_findings` for the two halves of that rule. + """ if isinstance(value, bytes): return value.decode(errors="replace") return value if isinstance(value, str) else "" @@ -402,6 +465,13 @@ def _findings(report: Path) -> Mapping[str, object] | None: A report that is absent and a report that will not parse are the same fact here — there are no findings — and neither is turned into a verdict. + + **Read strictly, and deliberately so.** `_harness` decodes the program's + two streams with replacement, because a program is free to print bytes; + this file is the other channel and gets the opposite rule. Bytes that are + not the UTF-8 the harness wrote are not repaired into something that might + parse — a mangled report answers « no findings », which is an inability to + verify, rather than a verdict read off characters nobody wrote. """ try: document = json.loads(report.read_text(encoding="utf-8")) @@ -451,11 +521,22 @@ def _rendered( f"events={point.get('events')}" ) if counted: + # The reasons the harness recorded for THIS point, not a sentence + # guessed here: a count can be made of several causes — a damaged + # range, a walk that stopped, a record that may be another + # execution's, a path nothing watches, a child's memory — and a line + # that always said one of them would misname the other four + # (article 2). Each is kept once and read off the report. + because = point.get(harness.INCOMPLETE) + reasons = ( + "; ".join(str(reason) for reason in because) + if isinstance(because, list) and because + else "no reason was recorded, so this run cannot say why" + ) lines.append( f"{harness.UNJUDGED}: {counted} {point.get('pack')} {point.get('module')}." - f"{point.get('attribute')} {point.get('capability')} — an effect named the " - f"program's own start file after it had started, so this run could not " - f"judge it and is not a pass (article 2)" + f"{point.get('attribute')} {point.get('capability')} — {reasons}. This run " + f"could not judge it and is not a pass (article 2)" ) if arguments.json: render.write_json( diff --git a/src/sayfirst_cli/packs/__init__.py b/src/sayfirst_cli/packs/__init__.py index 950e8b0..dcf2223 100644 --- a/src/sayfirst_cli/packs/__init__.py +++ b/src/sayfirst_cli/packs/__init__.py @@ -1,10 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 """Where the convenience packs this distribution ships live (article 9). -A directory of pack directories and nothing else: no loader here reads any of -them by name. `sayfirst instrument run --pack DIR` designates one by its path, -and `sayfirst packs list` reads this directory only to print that path back — -this file exists so `importlib.resources` resolves the directory as an -ordinary subpackage instead of a namespace package assembled from wherever it -is found on the path, which is one fewer thing a reader has to reason about. +A directory of pack directories and nothing else. `--pack NAME` designates one +of them by its name, looked up here and nowhere else, and `--pack DIR` any pack +by its path (`instrument/designation.py` is the whole of that rule); +`sayfirst packs list` reads this directory to print each name and path. This +file exists so `importlib.resources` resolves the directory as an ordinary +subpackage instead of a namespace package assembled from wherever it is found +on the path, which is one fewer thing a reader has to reason about. """ diff --git a/src/sayfirst_cli/packs/http-client/interpose.py b/src/sayfirst_cli/packs/http-client/interpose.py index 056c081..cca1e36 100644 --- a/src/sayfirst_cli/packs/http-client/interpose.py +++ b/src/sayfirst_cli/packs/http-client/interpose.py @@ -22,12 +22,88 @@ The wrapper is a plain function, so what it returns is the real response object, never a stand-in for it. + +**A redirect is a second request, and wrapping the one function does not see +it.** One `urlopen` of an address that answers `302` makes two requests, and +the second one is not a second call to `urlopen`: the standard library's +redirect handler goes back through the OPENER it is attached to. So a pack +that asked only where `urlopen` was called asked about the first address and +let the second request leave the process with no question put about it — while +the interpreter reported both, as two `urllib.Request` events, which is the +event this pack's manifest names for a verifier. The two shipped halves then +disagreed about an ordinary redirect: the primary mode let the second request +through ungoverned, and the verifier, reading one decision against two events, +aborted it as ungoverned. Neither half was wrong about what it saw. + +So for the length of one governed `urlopen` call, and no longer, the opener's +own `open` is governed too: the first of them is the request `urlopen` was +already asked about, and every one after it is a request the redirect +machinery made, each asked about under the same capability with its own +address. That puts exactly one ask against exactly one `urllib.Request` event +— which is the arithmetic the verifier does — for a redirect of any length, +and whatever host or scheme a hop moves to, since the address asked about is +the one the handler resolved. + +**It is the opener's `open` and not its redirect handler**, because that is +where the interpreter reports the event, and because a handler is an object an +opener was built with: an opener built before this pack was installed holds the +handler it was built with, and replacing the class would not reach it. `open` +is looked up on the opener's class at each call, so an opener built at any time +— before the engine ran, during the program's own imports, or never at all +because `urlopen` built the default one itself — goes through this. + +**Put back, always.** The governed `open` is in place only while at least one +governed `urlopen` call is in flight, is counted across threads so concurrent +calls do not restore it under each other, and is removed in a `finally`. A +call made on an opener directly, outside any `urlopen`, is not asked about — +the same requests that were not asked about before this — and a verifier +watching the same events says so as a finding rather than this pack pretending +otherwise. + +**What an outcome means across a redirect.** The recorded outcome is the status +of the response the ask's own request produced; for the request `urlopen` +started, that is the status the caller is handed, which after a redirect is the +final one. Each hop records the status its own `open` returned. Neither is +invented, and no ask is left with a status that belongs to another request. """ from __future__ import annotations import hashlib import inspect +import threading +from contextlib import contextmanager + +#: `urllib.request.OpenerDirector.open` exactly as this module found it, taken +#: once and never cleared, so that restoring it is giving back the very object +#: that was taken and never one that merely resembles it. +_THE_REAL_OPEN = None + +#: How many governed calls are in flight, across every thread. The governed +#: `open` goes in on the way from none to one and comes out on the way back. +_IN_FLIGHT = 0 + +_LOCK = threading.Lock() + +#: The governed calls this thread is inside, innermost last. Per thread, +#: because a redirect is followed on the thread that started the request, and +#: because a call another thread makes on an opener of its own is not this +#: call's second request. +_CALLS = threading.local() + + +class _Call: + """One governed `urlopen` call: whom to ask, and whether its own request is past.""" + + __slots__ = ("boundary", "capability", "entry_pending") + + def __init__(self, capability, boundary): + self.capability = capability + self.boundary = boundary + #: The first `open` of this call is the request the wrapper already + #: asked about. Asking again there would put two questions to the + #: boundary for one request. + self.entry_pending = True def wrap(original, capability, boundary): @@ -36,6 +112,10 @@ def wrap(original, capability, boundary): The ask carries `url` rendered as text — a `Request`'s own address, or `str()` of whatever else was passed — so the digest is computable whichever shape the caller called with. + + The opener is governed around the call, not around the ask: a refusal + arrives out of `boundary.request` before anything is opened, and then there + is no request to follow a redirect of. """ signature = inspect.signature(original) @@ -43,7 +123,8 @@ def wrapper(*args, **kwargs): bound = signature.bind_partial(*args, **kwargs) arguments = {"url": _rendered(bound.arguments.get("url"))} with boundary.request(capability, arguments) as handle: - response = original(*args, **kwargs) + with _following_the_redirects(capability, boundary): + response = original(*args, **kwargs) status = getattr(response, "status", None) if status is not None: # Nothing more of the response is worth an outcome: the status @@ -60,6 +141,81 @@ def wrapper(*args, **kwargs): return wrapper +@contextmanager +def _following_the_redirects(capability, boundary): + """Govern the opener's own `open` for the length of one `urlopen` call.""" + call = _Call(capability, boundary) + _began(call) + try: + yield + finally: + _ended() + + +def _stack(): + """This thread's governed calls, innermost last.""" + calls = getattr(_CALLS, "calls", None) + if calls is None: + calls = [] + _CALLS.calls = calls + return calls + + +def _began(call): + global _IN_FLIGHT, _THE_REAL_OPEN + # Imported here and not at the top of the file: a point may name a module + # the program has not loaded yet, and the engine loads this file to build + # the wrapper BEFORE that happens. Importing it up here would load it on + # the pack's behalf and take that case away from the engine. By the time a + # governed call is made, the module is loaded — the call is in it. + import urllib.request + + with _LOCK: + if _THE_REAL_OPEN is None: + _THE_REAL_OPEN = urllib.request.OpenerDirector.open + if _IN_FLIGHT == 0: + urllib.request.OpenerDirector.open = _asking_open + _IN_FLIGHT += 1 + _stack().append(call) + + +def _ended(): + global _IN_FLIGHT + import urllib.request + + _stack().pop() + with _LOCK: + _IN_FLIGHT -= 1 + if _IN_FLIGHT == 0: + urllib.request.OpenerDirector.open = _THE_REAL_OPEN + + +def _asking_open(self, *args, **kwargs): + """The opener's `open`, asked about unless it is the call's own first request. + + Reached three ways, and each is answered on what the thread is inside of: + the request `urlopen` made, which was asked about a moment ago and is let + through here; a request the redirect machinery made, which is asked about; + and a call on an opener made outside any governed `urlopen`, which is + neither this pack's to ask about nor its to change. + """ + calls = _stack() + if not calls: + return _THE_REAL_OPEN(self, *args, **kwargs) + call = calls[-1] + if call.entry_pending: + call.entry_pending = False + return _THE_REAL_OPEN(self, *args, **kwargs) + target = args[0] if args else kwargs.get("fullurl") + arguments = {"url": _rendered(target)} + with call.boundary.request(call.capability, arguments) as handle: + response = _THE_REAL_OPEN(self, *args, **kwargs) + status = getattr(response, "status", None) + if status is not None: + handle.record_outcome(_status_digest(status)) + return response + + def _rendered(value): """`url` as the digest sees it: a `Request`'s own address, or text of whatever else.""" if hasattr(value, "full_url"): @@ -68,5 +224,10 @@ def _rendered(value): def _status_digest(status): - """`sha256("status:" + str(status))`, as the manifest's `audit_event` expects to see it.""" + """`sha256("status:" + str(status))`: the outcome recorded for a request let through. + + Handed to the boundary's outcome log, which `instrument run` does not give + one — its boundary discards outcomes. The manifest declares no member for + them; a caller that builds a boundary with a log of its own receives this. + """ return hashlib.sha256(f"status:{status}".encode()).hexdigest() diff --git a/src/sayfirst_cli/packs/subprocess/interpose.py b/src/sayfirst_cli/packs/subprocess/interpose.py index ae66f81..b4196a4 100644 --- a/src/sayfirst_cli/packs/subprocess/interpose.py +++ b/src/sayfirst_cli/packs/subprocess/interpose.py @@ -9,10 +9,12 @@ `args` — a command line as text, or the sequence `Popen` accepts rendered as a list of text; anything else (there should be none: `Popen`'s first argument is -required) is rendered with `str()`, so the digest `pack.toml` declares -(`digest = ["args"]`) is always computable — is the one argument sent to the -boundary. Nothing else of the call is sent (article 11: what is sent is a -digest, and which arguments it covers is declared, never implicit). +required) is rendered with `str()` — is the one argument sent to the boundary, +as the digest `pack.toml` declares (`digest = ["args"]`). Nothing else of the +call is sent (article 11: what is sent is a digest, and which arguments it +covers is declared, never implicit). One shape has no digest: a name whose +bytes are not UTF-8 decodes to text the digest's encoding refuses, and the +boundary answers that question « could not ask » — the process is not spawned. **As text, whatever the caller's type.** `Popen` accepts a command line as `str`, as `bytes`, as a `os.PathLike`, or as a sequence of any of those, and @@ -86,5 +88,10 @@ def _text(value): def _pid_digest(pid): - """`sha256("pid:" + str(pid))`, as the manifest's `audit_event` expects to see it.""" + """`sha256("pid:" + str(pid))`: the outcome recorded for a spawn that was let through. + + Handed to the boundary's outcome log, which `instrument run` does not give + one — its boundary discards outcomes. The manifest declares no member for + them; a caller that builds a boundary with a log of its own receives this. + """ return hashlib.sha256(f"pid:{pid}".encode()).hexdigest() diff --git a/src/sayfirst_cli/packs/subprocess/pack.toml b/src/sayfirst_cli/packs/subprocess/pack.toml index 8e12f28..20df652 100644 --- a/src/sayfirst_cli/packs/subprocess/pack.toml +++ b/src/sayfirst_cli/packs/subprocess/pack.toml @@ -11,3 +11,29 @@ attribute = "Popen" capability = "process.spawn" digest = ["args"] audit_event = "subprocess.Popen" +# The other ways a process is created on this interpreter. This pack interposes +# none of them — wrapping them is a different pack's job, and blocking them +# would make `verify` a confinement mechanism — so it names them instead, and +# the verifier counts an effect that took one of these paths as an effect it +# could not judge rather than dropping it behind the verdict the interposed +# attribute earned. `docs/PACKS.md` § Coverage says what the count means. +# +# `_posixsubprocess.fork_exec` is how `multiprocessing` starts a process, and +# from Python 3.14 the default start method on Linux takes that path. Earlier +# interpreters raise no event for it at all, so there it stays uncounted: the +# documented limit, not a pass. +uninterposed_events = [ + "os.posix_spawn", + "_posixsubprocess.fork_exec", + "os.exec", + "os.spawn", + "os.system", + "os.fork", + "os.forkpty", + "pty.spawn", +] +# `Popen` creates the process it was asked for through the first two of those: +# `os.posix_spawn` when it can, `_posixsubprocess.fork_exec` otherwise. Each is +# that same act when it is the next event the calling thread raises and spawns +# the argument vector `Popen` was given, so it is not counted a second time. +inner_events = ["os.posix_spawn", "_posixsubprocess.fork_exec"] diff --git a/src/sayfirst_cli/packs_cmd.py b/src/sayfirst_cli/packs_cmd.py index ac4a7db..570caac 100644 --- a/src/sayfirst_cli/packs_cmd.py +++ b/src/sayfirst_cli/packs_cmd.py @@ -1,53 +1,30 @@ # SPDX-License-Identifier: Apache-2.0 """`sayfirst packs`: list and check the convenience packs this distribution ships. -Article 9's packs are designated by path, never by name — `instrument run ---pack` reads only the directory a person types, and consults no registry, no -default set and no name resolution of its own (`docs/PACKS.md`). This command -does not change that: `list` prints, for each pack this distribution ships -beside itself, the one path `--pack` would accept for it, so a person has -something to copy rather than something to guess; `check` reads a path the -same way the engine will, before a program is ever run with it. +Article 9's packs are designated by the person who runs them: by a path, or by +the name a pack ships under here, and `instrument/designation.py` is the whole +of that rule (`docs/PACKS.md` says why it is not a registry). This command is +the other side of it: `list` prints, for each pack this distribution ships +beside itself, the name `--pack` takes, what it asks about, and where it is, so +a person has something to read rather than something to guess; `check` reads a +designation the same way the engine will, before a program is ever run with it. Neither verb ends in a traceback. A pack that does not read is named with the member and the rule it broke, and the exit code says so — for `list` as much as for `check`, since the command a person uses to discover what is shipped is the one that has to be able to say that something shipped is broken. - -Read from the *installed* package with `importlib.resources`, never from a -path built off this file's own location: the packs this prints are the ones -this distribution actually carries, wherever it was installed. """ from __future__ import annotations import argparse -import importlib.resources import sys from collections.abc import Sequence -from pathlib import Path from typing import TextIO from . import exit_codes -from .instrument import manifest - - -def shipped_packs() -> list[Path]: - """Every pack directory this installed distribution carries, sorted by path. - - A directory counts as a pack candidate here by carrying a manifest, not by - its name — `__pycache__` and anything else beside the packs is silently - not a pack rather than a reason this call fails; `manifest.read_pack` - still refuses one that names a manifest but is not, in fact, complete. - """ - root = importlib.resources.files("sayfirst_cli.packs") - if not root.is_dir(): - return [] - return sorted( - Path(str(item)) - for item in root.iterdir() - if item.is_dir() and (item / manifest.MANIFEST_FILE).is_file() - ) +from .instrument import designation, manifest +from .instrument.designation import shipped_packs as shipped_packs def build_parser() -> argparse.ArgumentParser: @@ -57,8 +34,12 @@ def build_parser() -> argparse.ArgumentParser: ) verbs = parser.add_subparsers(dest="verb", required=True) verbs.add_parser("list", help="one line per shipped pack: name, capabilities, path") - checking = verbs.add_parser("check", help="read one pack directory the way the engine will") - checking.add_argument("path", metavar="PATH", help="the pack directory to read") + checking = verbs.add_parser("check", help="read one pack the way the engine will") + checking.add_argument( + "path", + metavar="PACK", + help="a shipped pack's name, or the path of a pack directory (./own-pack)", + ) return parser @@ -71,7 +52,7 @@ def main( arguments = build_parser().parse_args(forwarded) if arguments.verb == "list": return _list(stdout, stderr) - return _check(Path(arguments.path), stdout, stderr) + return _check(arguments.path, stdout, stderr) def _list(stdout: TextIO, stderr: TextIO) -> int: @@ -104,10 +85,21 @@ def _list(stdout: TextIO, stderr: TextIO) -> int: return exit_codes.EXIT_MISUSE if unread else 0 -def _check(path: Path, stdout: TextIO, stderr: TextIO) -> int: - """Read one pack, the way `instrument run` will, before anything is run with it.""" +def _check(named: str, stdout: TextIO, stderr: TextIO) -> int: + """Read one pack, the way `instrument run` and `verify` will, before anything runs. + + The same designation `--pack` takes, read by the same function, so that a + `check` that passes is a `run` that will read the same pack — and then the + two members only the verifier reads, by the verifier's own readers, so that + it is a `verify` that will read it too. + """ try: - pack = manifest.read_pack(path) + pack = designation.read(named) + # Imported here: the verifier's readers sit beside the hook that uses + # them, and `packs list` has no need of either. + from .instrument import harness + + harness.inner_events(pack) # which reads `uninterposed_events` first except manifest.PackInvalid as invalid: stderr.write(f"{invalid}\n") return exit_codes.EXIT_MISUSE diff --git a/src/sayfirst_cli/pages.py b/src/sayfirst_cli/pages.py index b313f93..fe3076d 100644 --- a/src/sayfirst_cli/pages.py +++ b/src/sayfirst_cli/pages.py @@ -17,9 +17,15 @@ from __future__ import annotations from collections.abc import Mapping +from typing import Final from . import reads +#: The kind of chain entry that records an effect: one of the contract's +#: `ENTRY_KINDS`, spelled once here and held to that tuple by +#: `tests/test_contract_words.py`. +EFFECT: Final[str] = "effect" + class UnreadablePage(ValueError): """An evidence page lacks a member the command that walks it consumes.""" @@ -59,7 +65,7 @@ def members( for name in ("kind", "connection_id", "entry_hash"): _checked(entry.get(name), str, f"{prefix}.{name}") body = _checked(entry.get("body"), Mapping, f"{prefix}.body") - if entry["kind"] == "effect": + if entry["kind"] == EFFECT: # The member `trace` reads to decide whether an entry is the one it # was asked about: a page that lacks it cannot answer the question. _checked(body.get("decision_id"), str, f"{prefix}.body.decision_id") diff --git a/src/sayfirst_cli/reads.py b/src/sayfirst_cli/reads.py index e402d79..ff83881 100644 --- a/src/sayfirst_cli/reads.py +++ b/src/sayfirst_cli/reads.py @@ -13,15 +13,17 @@ from sayfirst_contract.client import Answered, CouldNotAsk, Refused, Result from sayfirst_contract.generation import CONTRACT_GENERATION -from sayfirst_contract.problems import Problem, ProblemCode, problem_retryable +from sayfirst_contract.problems import REFUSED, Problem, ProblemCode, problem_retryable from sayfirst_contract.transport.socket_client import ( PER_USER, SYSTEM, + ProfileAddress, ProfileMisuse, SocketClientProblem, SocketProfile, VerifiedConnection, connect, + profile_address, ) from . import exit_codes, render @@ -46,6 +48,17 @@ One tuple, because five copies of it is how a sixth caller comes to spell none. """ +READ_TIMEOUT: Final[float] = 5.0 +"""How long one step of a connection to the daemon may wait, in seconds. + +A process that accepts a connection and then never answers held a command for +ever: every read, and `ask`, opened a connection with no bound. Each step — the +connect, the credential, each read — now waits at most this long, and one that +runs past it is the control plane that could not be asked. The launcher's own +bound on a governed effect's question (`instrument.launch.ASK_TIMEOUT`) is the +longer of the two, because a question may wait on a policy being read. +""" + DOCUMENT_DEPTH_LIMIT: Final = 64 """How deeply nested a daemon document may be before this client declines to read it. @@ -152,10 +165,38 @@ def read[T](operation: Callable[[], Result[T]]) -> Result[T]: return unreadable(failure) +SOCKET_HELP: Final = ( + "the path of the daemon's socket; a per-user profile given none is looked for at the " + "per-user default address, and a system profile always names it" +) +"""One sentence for every verb that takes the option, so none of them says less.""" + + +def add_socket_argument(parser: argparse.ArgumentParser) -> None: + """`--socket`, optional, on every verb that opens a connection. + + Optional because a per-user daemon given no address and a per-user client + given none read the same rule of the contract and meet at its answer. It is + an override the moment it is given: what a person typed is the address. + """ + parser.add_argument("--socket", default=None, help=SOCKET_HELP) + + +def address_of(arguments: argparse.Namespace) -> ProfileAddress: + """Where this invocation's profile is looked for, and whether anybody said so. + + The contract's rule and nothing beside it: no environment variable of this + client's own, no file, no search. It raises `ProfileMisuse` for a system + profile that names no socket, which every caller already renders as the + misuse it is. + """ + return profile_address(arguments.socket, arguments.mode) + + def add_connection_arguments(parser: argparse.ArgumentParser) -> None: """Every read names its scope explicitly, with the writer's connection options.""" parser.add_argument("--scope", required=True, help="the scope the question is asked in") - parser.add_argument("--socket", required=True, help="the path of the daemon's socket") + add_socket_argument(parser) parser.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER) parser.add_argument( "--daemon-user", @@ -167,7 +208,7 @@ def add_connection_arguments(parser: argparse.ArgumentParser) -> None: def connection_problem(failure: SocketClientProblem) -> Refused | CouldNotAsk: """A transport exception is a non-answer, with the contract's classification.""" - if failure.classification == "refused": + if failure.classification == REFUSED: return Refused(failure.problem) return CouldNotAsk(failure.problem) @@ -175,8 +216,9 @@ def connection_problem(failure: SocketClientProblem) -> Refused | CouldNotAsk: def open_connection(arguments: argparse.Namespace, stderr: TextIO) -> VerifiedConnection | int: """Verify the daemon, or report why this invocation could not open a connection.""" try: + address = address_of(arguments) profile = SocketProfile( - arguments.socket, + address.path, mode=arguments.mode, daemon_user=arguments.daemon_user, scope=arguments.scope, @@ -185,8 +227,9 @@ def open_connection(arguments: argparse.Namespace, stderr: TextIO) -> VerifiedCo stderr.write(f"{misuse}\n") return exit_codes.EXIT_MISUSE try: - return connect(profile) + return connect(profile, timeout=READ_TIMEOUT) except SocketClientProblem as failure: + say_where_it_looked(address, arguments, stderr) return _write_problem( connection_problem(failure), arguments, @@ -195,6 +238,19 @@ def open_connection(arguments: argparse.Namespace, stderr: TextIO) -> VerifiedCo ) +def say_where_it_looked( + address: ProfileAddress, arguments: argparse.Namespace, stderr: TextIO +) -> None: + """Name an address nobody typed, on the way to saying that it did not answer. + + A reader cannot act on « unreachable » at a path they never saw. Said in + prose only: under `--json` the envelope is the whole of what this client + writes, and a sentence beside it would break the reader it was asked for. + """ + if address.defaulted and not getattr(arguments, "json", False): + stderr.write(f"{address.looked_at()}\n") + + def finish( result: Result[DocumentValue | Mapping[str, object]], arguments: argparse.Namespace, @@ -211,7 +267,7 @@ def finish( # used: `to_document` is itself a step an unreadable answer raises inside, # and the render below is the step a document nested past the bound would # overflow. Either way the answer is one this client could not read. - answer = read(lambda: _bounded(result)) if isinstance(result, Answered) else result + answer = read(lambda: bounded(result)) if isinstance(result, Answered) else result if isinstance(answer, Answered): document = answer.value if arguments.json: @@ -224,7 +280,7 @@ def finish( return _write_problem(answer, arguments, verification, stderr) -def _bounded( +def bounded( result: Answered[DocumentValue | Mapping[str, object]], ) -> Answered[Mapping[str, object]]: """The answer as the document about to be rendered, refused if it nests too deep.""" diff --git a/src/sayfirst_cli/render.py b/src/sayfirst_cli/render.py index 99dfc4f..133f0bb 100644 --- a/src/sayfirst_cli/render.py +++ b/src/sayfirst_cli/render.py @@ -85,7 +85,11 @@ def write_decision(document: Mapping[str, object], stream: TextIO) -> None: def write_verification(document: Mapping[str, object], stream: TextIO) -> None: - """What was verified about the far end, in the same words `whoami` uses.""" + """What was verified about the far end, in the words `sayfirstd whoami` uses. + + One difference, on purpose: an absent value is said here (`not stated`), + where that command prints Python's `None`. + """ stream.write( f"verified: {str(document['verified']).lower()} " f"(server_uid {_stated(document['server_uid'])}, " diff --git a/src/sayfirst_cli/trace.py b/src/sayfirst_cli/trace.py index 99df133..6aae7f5 100644 --- a/src/sayfirst_cli/trace.py +++ b/src/sayfirst_cli/trace.py @@ -48,11 +48,12 @@ def walk() -> Result[Mapping[str, object]]: record = result.value.to_document() from_sequence, read_pages, position = 1, 0, None for _ in range(arguments.pages): - # The daemon closes the connection after a decision read (an adapter - # wrote that answer), and an evidence read may close its own. The - # transport never re-opens an address on a caller's behalf (rule - # C4), so every page is read on a connection reopened — and the far - # end verified again — here, exactly as `history` walks its pages. + # A daemon may close the connection after any answer (the one this + # client was written against keeps it open after a document, which + # is a behaviour and not a promise), and the transport never + # re-opens an address on a caller's behalf (rule C4). So every page + # is read on a connection reopened — and the far end verified + # again — here, exactly as `history` walks its pages. connection.reconnect() page = connection.read_evidence(arguments.scope, from_sequence, 100) if not isinstance(page, Answered): @@ -60,7 +61,10 @@ def walk() -> Result[Mapping[str, object]]: read_pages += 1 entries, verification, next_from = pages.members(page.value, from_sequence) for entry in entries: - if entry["kind"] == "effect" and entry["body"]["decision_id"] == arguments.decision: + if ( + entry["kind"] == pages.EFFECT + and entry["body"]["decision_id"] == arguments.decision + ): grade = next( ( item["grade"] diff --git a/tests/canned_daemon.py b/tests/canned_daemon.py index d5e61d8..6256911 100644 --- a/tests/canned_daemon.py +++ b/tests/canned_daemon.py @@ -74,12 +74,22 @@ def log_message(self, format: str, *args: object) -> None: @contextmanager def answering_by_path( socket_path: Path, - routes: dict[str, tuple[int, Mapping[str, object] | Sequence[Mapping[str, object]]]], + routes: dict[ + str, + tuple[ + int, + Mapping[str, object] + | Sequence[Mapping[str, object] | tuple[int, Mapping[str, object]]], + ], + ], *, close_after_each: bool = False, ) -> Iterator[Path]: """Match the longest path prefix; advance document sequences, repeating the last. + An item of a sequence may be `(status, document)`, answered with that status + instead of the route's. + With `close_after_each`, every answer carries `Connection: close` and the server hangs up after it — what the real daemon does after an answer an adapter wrote (a decision read), and what a command reading twice must @@ -109,6 +119,10 @@ def do_GET(self) -> None: else: document = documents[min(positions[prefix], len(documents) - 1)] positions[prefix] += 1 + if isinstance(document, tuple): + # One answer of the sequence with a status of its own: + # a read answered, and the next one refused. + status, document = document break body = json.dumps(document).encode() self.send_response(status) diff --git a/tests/documents.py b/tests/documents.py index eb42b2d..34e5fa0 100644 --- a/tests/documents.py +++ b/tests/documents.py @@ -11,6 +11,8 @@ from __future__ import annotations +import os +import pwd from collections.abc import Mapping from copy import deepcopy @@ -193,6 +195,33 @@ def no_evidence_page(*, from_sequence: int = 1) -> dict[str, object]: } +#: The principal a record carries when it was NOT recorded for the execution +#: under proof. A name no account on any host is spelled with, so that « this +#: record is somebody else's » is a property of the fixture and never an +#: accident of whoever runs the suite. +ANOTHER_ACCOUNT: dict[str, object] = {"kind": "user", "id": "another-account!", "via": []} + +#: The connection a record carries when another execution asked for it. The +#: daemon gives one connection one identifier, so two executions never share it. +ANOTHER_EXECUTION = "another-execution" + + +def this_executions_principal() -> dict[str, object]: + """The principal the daemon records for a connection from THIS process. + + Article 6: identity is the operating system's, and the daemon builds the + principal from the peer credential of the connection — the account this + process runs as. A fixture claiming to be a record of the run under proof + has to carry that identity, because that is the one thing about a record + which says the run under proof is the run it was recorded for. + """ + try: + name: object = pwd.getpwuid(os.geteuid()).pw_name + except KeyError: # pragma: no cover - a uid with no account + name = str(os.geteuid()) + return {"kind": "user", "id": name, "via": []} + + def recorded_effect_page( capability: str, *, @@ -201,12 +230,27 @@ def recorded_effect_page( from_sequence: int = 1, next_from: int | None = None, count: int = 1, + principal: Mapping[str, object] | None = None, + connection_id: str = "connection-1", + condition: str | None = None, ) -> dict[str, object]: - """A page holding recorded effects, which is what the verifier reads. - - The verifier matches an `effect` entry by its body's capability and - outcome, so those two are the members this varies; everything else is the - contract's published example of an entry and of a page. + """A page holding recorded effects of the execution under proof. + + **What this fixture carries, and why it is not two members.** It varied + exactly the two members the matcher consumed — the body's capability and + its outcome — and so it could not see the defect that the matcher consumed + only those two: a record of another execution, another principal and + another call satisfied every success fixture in this repository. The + invariant that replaces « vary what the matcher reads » is « a success + fixture carries the identity of the execution under proof »: by default the + principal this process would be recorded as, and one connection identifier. + `ANOTHER_ACCOUNT` and `ANOTHER_EXECUTION` are what a record of somebody + else looks like, and a fixture built from either is not a success fixture. + + `condition` overrides what the served verification says about the range the + record sits in. The default is the contract's own example, which reports the + chain intact; a page reporting anything else is evidence this client may not + take a record from, and saying so is this argument's whole purpose. `count` puts several records of the one capability on the page, from `sequence` upwards. A test that has to prove the verifier counted the @@ -220,6 +264,8 @@ def recorded_effect_page( **examples["evidence-entry"], "sequence": sequence + offset, "entry_hash": f"{sequence + offset:064d}", + "connection_id": connection_id, + "principal": dict(this_executions_principal() if principal is None else principal), "body": { **examples["evidence-entry"]["body"], "capability": capability, @@ -229,22 +275,41 @@ def recorded_effect_page( for offset in range(count) ] last = sequence + count - 1 + verification = { + **examples["evidence-verdict"], + "from_sequence": from_sequence, + "to_sequence": last, + "up_to": last, + } + if condition is not None: + verification["condition"] = condition + verification["sequence"] = sequence return { **examples["evidence-page-result"], "contract_version": str(CONTRACT_GENERATION), "from_sequence": from_sequence, - "to_sequence": last, "entries": entries, - "verification": { - **examples["evidence-verdict"], - "from_sequence": from_sequence, - "to_sequence": last, - "up_to": last, - }, + "to_sequence": last, + "verification": verification, "next_from": next_from, } +def another_executions_effect_page(capability: str, **changes: object) -> dict[str, object]: + """The same page, recorded for another principal on another connection. + + Nothing about it says it is this run's, which is the whole of it: a + verifier that reports `governed` from this page has proven that a decision + exists somewhere, never that one preceded the effect it watched. + """ + return recorded_effect_page( + capability, + principal=ANOTHER_ACCOUNT, + connection_id=ANOTHER_EXECUTION, + **changes, # type: ignore[arg-type] + ) + + def foreign_generation_page(*, from_sequence: int = 1) -> dict[str, object]: """A page a daemon of a generation this client does not speak would serve. diff --git a/tests/test_approvals.py b/tests/test_approvals.py index 1db9864..fc1951c 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -12,6 +12,7 @@ import io import json +import os import pytest from canned_daemon import answering_by_path @@ -40,9 +41,9 @@ def replay(monkeypatch): def arrange(*responses): http = Replies(responses) - def connect(profile): + def connect(profile, **_): assert profile.scope == "team-ops" - assert profile.socket_path == "daemon.sock" + assert profile.socket_path == os.path.abspath("daemon.sock") return verified(profile, http) monkeypatch.setattr(reads, "connect", connect) @@ -97,6 +98,35 @@ def test_show_renders_an_already_approved_wait(replay): assert "reason: looked fine\n" in stdout +def test_show_names_the_person_who_acted(replay): + """Article 12: the record names the person. The prose form rendered six of the + record's members and never that one, so a person reading `show` could not see + who had approved what they were looking at.""" + replay( + ( + 200, + approval_record( + state="approved", + resolved_at="2026-09-16T00:00:30+00:00", + resolution_reason="looked fine", + person="alice", + ), + ) + ) + code, stdout, stderr = run("show", *arguments()) + assert code == 0, stderr + assert "person: alice\n" in stdout + + +def test_a_wait_nobody_acted_on_names_no_person(replay): + """Absent rather than « not stated »: a wait nobody acted on has no person, and + the contract renders the member only where there is one.""" + replay((200, approval_record(state="pending"))) + code, stdout, _ = run("show", *arguments()) + assert code == 0 + assert "person:" not in stdout + + def test_show_json_preserves_the_whole_record(replay): document = approval_record(state="pending") replay((200, document)) diff --git a/tests/test_contract_words.py b/tests/test_contract_words.py new file mode 100644 index 0000000..9c0e5e8 --- /dev/null +++ b/tests/test_contract_words.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The contract's words this client still spells itself, held to where they are published. + +Most of the contract's vocabulary is imported: a problem's class, a decision's +outcome, a chain's condition. Two values are not published as names a client +can import — the kind of entry that records an effect is one member of a tuple, +and the largest page a read may ask for is a bound in the binding's own +description — so this client spells them once, and these tests fail the day the +contract says something else. +""" + +from __future__ import annotations + +import pytest +from contract_absence import contract_is_installed, skip_without_the_contract + + +@pytest.fixture(autouse=True) +def _the_contract(request: pytest.FixtureRequest) -> None: + if not contract_is_installed(): + skip_without_the_contract(request, "these values are read from the contract") + + +def test_the_effect_kind_is_one_the_contract_publishes() -> None: + from sayfirst_contract.evidence import ENTRY_KINDS + + from sayfirst_cli import pages + + assert pages.EFFECT in ENTRY_KINDS + + +def test_no_read_asks_for_a_page_larger_than_the_binding_serves() -> None: + from sayfirst_contract.artifacts import load_json + + from sayfirst_cli.instrument import harness + + description = load_json("binding", "http-unix-socket", "openapi.json") + parameters = description["paths"]["/scopes/{scope}/evidence"]["get"]["parameters"] + (page_size,) = [item for item in parameters if item["name"] == "page_size"] + assert page_size["schema"]["maximum"] >= harness.PAGE_SIZE diff --git a/tests/test_decided_name.py b/tests/test_decided_name.py index 3ff8e97..feae83b 100644 --- a/tests/test_decided_name.py +++ b/tests/test_decided_name.py @@ -60,7 +60,19 @@ #: The decided name with a letter welded to it — the shape a blanket substring #: replacement leaves behind, and the defect this project has already had. -GLUED_NAME = re.compile("(? N found = list(occurrences(tmp_path)) assert [item[1] for item in found] == ["the decided name glued into a word"], (text, found) + # The operator surface's own name is a word and is admitted; the same stem + # running on into a longer word is the defect again and is not. + operator = DECIDED_NAME + _OPERATOR_SUFFIX + for text in (f"{operator} status", f"`{operator}`", f"{operator}."): + planted.write_text(f"Type {text}\n", encoding="utf-8") + assert list(occurrences(tmp_path)) == [], text + for text in (f"{operator}aemon", f"{operator}x"): + planted.write_text(f"Type {text} here.\n", encoding="utf-8") + found = list(occurrences(tmp_path)) + assert [item[1] for item in found] == ["the decided name glued into a word"], (text, found) + planted.unlink() at_path = tmp_path / "src" / ("oss" + "_" + "brand" + "_cli") at_path.mkdir(parents=True) diff --git a/tests/test_default_socket.py b/tests/test_default_socket.py new file mode 100644 index 0000000..e78e8f6 --- /dev/null +++ b/tests/test_default_socket.py @@ -0,0 +1,211 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A per-user profile that names no socket is looked for at the per-user default. + +The rule is the contract's, read and never restated: a per-user daemon given no +address binds `default_socket_path`, and this client given no `--socket` looks +at the same function's answer, so neither is told where the other is. Three +things are held here because they are what a default could quietly break. + +* **An explicit `--socket` is still the address.** A default that outranked + what a person typed would ask a daemon they did not name. +* **Nothing is searched for.** One name is computed from the environment; a + daemon serving anywhere else is not found, and that is « could not ask » + (exit 4) — never a denial, and never permission (articles 1 and 2). +* **An address nobody typed is said when it fails**, because a reader cannot + act on « unreachable » at a path they never saw. + +A system profile gets no default: it already has to say which account the +daemon runs as, and where is the other half of the same sentence. +""" + +from __future__ import annotations + +import io +import json +import shutil +import tempfile +from collections.abc import Iterator +from pathlib import Path + +import pytest +from governed_programs import SPAWNING_APP, instrument, plain_environment, plant_spawn_pack +from sayfirst_contract_stub.stub import Stub +from sayfirst_contract_stub.stub_http import serve + +from sayfirst_cli import approvals, ask, exit_codes + + +@pytest.fixture +def home(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: + """A home directory with no runtime directory, short enough to hold an address. + + Made directly under the system's temporary directory rather than under + `tmp_path`: a local address has about a hundred bytes, and the runner's own + directory for a test spends most of them before the default rule has added + its four levels. + """ + directory = Path(tempfile.mkdtemp(prefix="sf", dir="/tmp")) + monkeypatch.setenv("HOME", str(directory)) + monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False) + try: + yield directory + finally: + shutil.rmtree(directory, ignore_errors=True) + + +def default_address(home: Path) -> Path: + address = home / ".sayfirst" / "run" / "daemon.sock" + address.parent.mkdir(parents=True, exist_ok=True) + return address + + +def asked(*argv: str) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + return ask.main(list(argv), out=out, err=err), out.getvalue(), err.getvalue() + + +def test_ask_given_no_socket_asks_the_daemon_at_the_per_user_default(home: Path) -> None: + stub = Stub("allow") + with serve(stub, default_address(home)): + code, out, err = asked("--capability", "example.read", "--scope", "local") + assert code == exit_codes.EXIT_ALLOW, err + assert "outcome: allow" in out + assert stub.decision_count == 1 + + +def test_a_socket_somebody_named_is_the_one_that_is_asked(home: Path, tmp_path: Path) -> None: + at_the_default, named = Stub("deny"), Stub("allow") + with serve(at_the_default, default_address(home)), serve(named, home / "own.sock") as own: + code, out, _ = asked("--capability", "example.read", "--socket", str(own)) + assert code == exit_codes.EXIT_ALLOW + assert (named.decision_count, at_the_default.decision_count) == (1, 0) + + +def test_nobody_at_the_default_is_could_not_ask_and_says_where_it_looked(home: Path) -> None: + code, out, err = asked("--capability", "example.read") + assert code == exit_codes.EXIT_COULD_NOT_ASK + assert out == "" + assert f"socket: {home}/.sayfirst/run/daemon.sock (the per-user default" in err + assert "could not ask: unreachable" in err + + +def test_the_envelope_is_still_the_only_thing_on_the_stream_under_json(home: Path) -> None: + code, _, err = asked("--capability", "example.read", "--json") + assert code == exit_codes.EXIT_COULD_NOT_ASK + assert json.loads(err)["problem"]["code"] == "unreachable" + + +def test_a_socket_somebody_named_is_not_announced_as_a_default(home: Path) -> None: + code, _, err = asked("--capability", "example.read", "--socket", str(home / "absent.sock")) + assert code == exit_codes.EXIT_COULD_NOT_ASK + assert "per-user default" not in err + + +def test_a_daemon_serving_somewhere_else_is_not_gone_looking_for(home: Path) -> None: + elsewhere = Stub("allow") + with serve(elsewhere, home / "elsewhere.sock"): + code, _, _ = asked("--capability", "example.read") + assert code == exit_codes.EXIT_COULD_NOT_ASK + assert elsewhere.decision_count == 0 + + +def test_a_system_profile_is_never_given_a_default_address(home: Path) -> None: + code, out, err = asked( + "--capability", "example.read", "--mode", "system", "--daemon-user", "root" + ) + assert code == exit_codes.EXIT_MISUSE + assert "--socket is required in system mode" in err + + +def test_the_reads_look_at_the_same_default(home: Path) -> None: + """One helper opens every read's connection, so one read stands for them.""" + out, err = io.StringIO(), io.StringIO() + code = approvals.main( + ["show", "--approval", "0d2f7a52-8f0e-4c55-9f59-0e4f6f3f7c11", "--scope", "local"], + out=out, + err=err, + ) + assert code == exit_codes.EXIT_COULD_NOT_ASK + assert f"socket: {home}/.sayfirst/run/daemon.sock (the per-user default" in err.getvalue() + + +def _environment(home: Path) -> dict[str, str]: + environment = plain_environment() + environment.pop("XDG_RUNTIME_DIR", None) + environment["HOME"] = str(home) + return environment + + +def test_a_governed_program_given_no_socket_is_asked_about_at_the_default( + home: Path, tmp_path: Path +) -> None: + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text(SPAWNING_APP, encoding="utf-8") + pack = plant_spawn_pack(tmp_path) + stub = Stub("allow") + with serve(stub, default_address(home)): + finished = instrument( + "run", "--pack", str(pack), "--scope", "local", "--", "app.py", + cwd=tree, env=_environment(home), + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert stub.decision_count == 1 + + +def test_a_governed_effect_with_nobody_at_the_default_fails_closed( + home: Path, tmp_path: Path +) -> None: + """The effect does not happen, the run ends « could not ask », and the + address nobody typed is on the error stream before the program starts. + + The program does not handle the outcome, so the traceback is still its + own; the status is this client's, because the interpreter's `1` is this + client's « deny » and nobody was there to deny anything.""" + tree = tmp_path / "tree" + tree.mkdir() + marker = tree / "ran" + (tree / "app.py").write_text( + f'import subprocess\n\nsubprocess.run(["touch", {str(marker)!r}], check=True)\n', + encoding="utf-8", + ) + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "run", "--pack", str(pack), "--scope", "local", "--", "app.py", + cwd=tree, env=_environment(home), + ) # fmt: skip + assert not marker.exists() + assert finished.returncode == exit_codes.EXIT_COULD_NOT_ASK + assert "CouldNotAsk" in finished.stderr + assert f"socket: {home}/.sayfirst/run/daemon.sock (the per-user default" in finished.stderr + + +def test_a_program_that_asks_nothing_still_runs_with_nobody_there( + home: Path, tmp_path: Path +) -> None: + """The default changes where a question goes, never whether a program runs.""" + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text('print("quiet")\n', encoding="utf-8") + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "run", "--pack", str(pack), "--scope", "local", "--", "app.py", + cwd=tree, env=_environment(home), + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert finished.stdout == "quiet\n" + + +def test_a_verification_with_nobody_at_the_default_concludes_nothing_and_says_where( + home: Path, tmp_path: Path +) -> None: + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text(SPAWNING_APP, encoding="utf-8") + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "verify", "--pack", str(pack), "--scope", "local", "--", "app.py", + cwd=tree, env=_environment(home), + ) # fmt: skip + assert finished.returncode == exit_codes.EXIT_COULD_NOT_ASK, finished.stderr + assert f"socket: {home}/.sayfirst/run/daemon.sock (the per-user default" in finished.stderr diff --git a/tests/test_engine.py b/tests/test_engine.py index 329f34f..6612c3c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -509,3 +509,60 @@ def test_a_stale_bytecode_beside_the_pack_is_never_what_runs( loaded = importlib.import_module(name) assert getattr(loaded.effect, "wrapped_by_the_pack", False) is True assert not getattr(loaded.effect, "from_decoy", False) + + +def test_a_refused_import_refuses_again_when_the_program_retries_it( + tmp_path: Path, target, engine: Engine +) -> None: + """The refusal is the engine's, so a retry has to meet it too. + + The one refusal that surfaces inside the program's own import is the + program's to handle, and handling it commonly means trying the import + again. Measured before this rule: the first import raised, Python dropped + the failed module from `sys.modules`, and the points waiting for it were + already gone — so the finder no longer claimed the module and the SECOND + import succeeded entirely unwrapped, with the effect reachable and the + boundary never asked. A refusal that governs one import and no other is a + refusal that turns governance off for the module it refused. + """ + name = target() + boundary = FakeBoundary() + engine.install( + [plant_two_point_pack(tmp_path, name, second="no_such_attribute")], + boundary, + scope="local", + ) + with pytest.raises(EngineMisuse) as first: + importlib.import_module(name) + assert "no_such_attribute" in str(first.value) + assert name not in sys.modules + + with pytest.raises(EngineMisuse) as retried: + importlib.import_module(name) + assert "no_such_attribute" in str(retried.value) + # Nothing of the module is reachable, so the effect cannot have been + # called unwrapped — and the boundary was never asked, because nothing + # ran that would have asked it. + assert name not in sys.modules + assert boundary.asked == [] + + +def test_a_deferred_install_that_succeeds_leaves_no_point_waiting( + tmp_path: Path, target, engine: Engine +) -> None: + """The other side of keeping a refused module's points: a good install lets go. + + Points kept past a successful import would leave the finder claiming a + module it has already wrapped, and a later re-import wrapping over work + this engine had already done. + """ + name = target() + boundary = FakeBoundary() + engine.install([pack_for(tmp_path, name)], boundary, scope="local") + assert engine._awaiting(name) is True + loaded = importlib.import_module(name) + assert getattr(loaded.effect, "wrapped_by_the_pack", False) is True + assert engine._awaiting(name) is False + assert engine._awaited == {} + assert loaded.effect(1) == ("effect", 1, 2) + assert boundary.asked == [("example.action", {"first": 1})] diff --git a/tests/test_evidence_export_and_exports.py b/tests/test_evidence_export_and_exports.py index 1f53a1b..73984a3 100644 --- a/tests/test_evidence_export_and_exports.py +++ b/tests/test_evidence_export_and_exports.py @@ -64,9 +64,9 @@ def arrange(status, document): else: http = Replies([(status, document)]) - def connect(profile): + def connect(profile, **_): assert profile.scope == "local" - assert profile.socket_path == "daemon.sock" + assert profile.socket_path == os.path.abspath("daemon.sock") return verified(profile, http) monkeypatch.setattr(reads, "connect", connect) @@ -297,8 +297,10 @@ def test_export_rejects_invalid_ranges(tmp_path, no_socket, extra): assert failure.value.code == 2 -@pytest.mark.parametrize("missing", ["--scope", "--socket", "--from", "--out"]) +@pytest.mark.parametrize("missing", ["--scope", "--from", "--out"]) def test_export_requires_its_arguments(tmp_path, no_socket, missing): + # `--socket` left this list when a per-user profile given none began to be + # looked for at the per-user default address (`tests/test_default_socket.py`). arguments = list(export_arguments(tmp_path / "out.json", "absent.sock")) index = arguments.index(missing) del arguments[index : index + 2] @@ -332,7 +334,7 @@ def forbidden(*args, **kwargs): monkeypatch.setattr( reads, "connect", - lambda profile: verified(profile, http), + lambda profile, **_: verified(profile, http), ) monkeypatch.setattr(VerifiedConnection, "export_evidence", read) monkeypatch.setattr(evidence, "verify_export", verify) @@ -363,7 +365,7 @@ def responses(): monkeypatch.setattr( reads, "connect", - lambda profile: verified(profile, http), + lambda profile, **_: verified(profile, http), ) code, stdout, stderr = run(*export_arguments(path, "daemon.sock")) assert code == exit_codes.EXIT_MISUSE @@ -676,3 +678,30 @@ def test_export_finishes_against_a_daemon_that_closes_after_its_answer(tmp_path) assert code == exit_codes.EXIT_COULD_NOT_CHECK assert f"saved: {path} (3 entries)\n" in stdout assert stderr == "" + + +def test_a_bundle_that_cannot_be_written_is_could_not_check_not_a_traceback( + tmp_path, daemon, monkeypatch +): + """The bundle is saved unbounded, so the step that writes it is the one that can fail. + + On an interpreter whose indenting encoder recurses, a bundle nested deep + enough exhausted it inside the save, and the export ended in a traceback + with status 1 — this client's « deny ». Forced here on every interpreter. + """ + from sayfirst_cli import evidence + + path = tmp_path / "saved.json" + real_dumps = evidence.json.dumps + + def recursing(document, **options): + if options.get("indent"): + raise RecursionError("maximum recursion depth exceeded while encoding a JSON object") + return real_dumps(document, **options) + + with daemon(200, bundle()) as (address, _): + monkeypatch.setattr(evidence.json, "dumps", recursing) + code, stdout, stderr = run(*export_arguments(path, address)) + assert code == 7, (stdout, stderr) + assert "could not save" in stderr + assert not path.exists() diff --git a/tests/test_evidence_history_and_audit.py b/tests/test_evidence_history_and_audit.py index 075280d..da4591f 100644 --- a/tests/test_evidence_history_and_audit.py +++ b/tests/test_evidence_history_and_audit.py @@ -5,12 +5,20 @@ import io import json +from copy import deepcopy from pathlib import Path import pytest from canned_daemon import answering_by_path -from documents import arguments, chain_page_nested, entry_lines, evidence_page, problem -from sayfirst_contract.evidence import ChainCondition, verify_chain +from documents import ( + arguments, + chain_page_nested, + entry_lines, + evidence_page, + problem, + vector_entries, +) +from sayfirst_contract.evidence import ChainCondition, chained_document, verify_chain from sayfirst_contract.generation import CONTRACT_GENERATION from sayfirst_cli import exit_codes @@ -59,8 +67,14 @@ def test_history_prints_every_entry_and_the_end(tmp_path: Path, entries): @pytest.mark.parametrize("as_json", [False, True]) def test_audit_merges_every_served_verdict_and_checks_all_entries(tmp_path: Path, entries, as_json): """Asserted metadata is rendered from the pages, never re-derived by the CLI: gaps - in page order, a grade per connection with the later page's winning, and the count - of pages the verdict is made of. `daemon` was graded on page 1 only and survives.""" + in page order, one grade per connection — the weakest any page gave it, so a later + page's `evidence` does not lift a range page 1 graded `observability` — and the count + of pages the verdict is made of. `daemon` was graded on page 1 only and survives. + + This test used to expect the LATER page's grade to win, including a stronger one. That + was the merge's implementation restated as an expectation, and it contradicted the rule + it should have been holding the merge to: article 7 makes a verdict's grade the weakest + grade in effect over the period covered, and a merged verdict covers every page.""" pages = [ evidence_page(entries[:2], next_from=3), evidence_page(entries[2:], from_sequence=3, verified_entries=entries), @@ -79,7 +93,7 @@ def test_audit_merges_every_served_verdict_and_checks_all_entries(tmp_path: Path **pages[-1]["verification"], "declared_gaps": [{"sequence": 2, "reason": "purged", "count": 4}], "grades": [ - {"connection_id": "connection-1", "grade": "evidence"}, + {"connection_id": "connection-1", "grade": "observability"}, {"connection_id": "daemon", "grade": "unverified"}, ], }, @@ -92,12 +106,84 @@ def test_audit_merges_every_served_verdict_and_checks_all_entries(tmp_path: Path "pages: 2", "local_check: intact", "gap: sequence 2 reason purged count 4", - "grade: connection-1 evidence", + "grade: connection-1 observability", "grade: daemon unverified", ] assert "finding:" not in stdout +def graded_chain(*records) -> list[dict[str, object]]: + """A real chain of grade records, each placed after its predecessor by the contract. + + `chained_document` is the contract's own writer-side recipe, so what this + builds is a chain the contract's verifier accepts — not a shape invented + here to make an assertion come out. + """ + template = next(entry for entry in vector_entries() if entry["kind"] == "grade") + chain: list[dict[str, object]] = [] + for connection_id, grade in records: + record = deepcopy(template) + record["connection_id"] = connection_id + record["body"]["grade"] = grade + chain.append(chained_document(record, chain[-1] if chain else None)) + return chain + + +def page_verified_over_its_own_range(entries, *, from_sequence, next_from=None): + """One page whose served verdict covers that page's range, as the daemon serves it.""" + page = evidence_page(entries, from_sequence=from_sequence, next_from=next_from) + page["verification"] = verify_chain( + entries, scope="local", from_sequence=from_sequence + ).to_document() + return page + + +@pytest.mark.parametrize("as_json", [False, True]) +def test_audit_grades_each_connection_by_its_weakest_grade_over_the_whole_range( + tmp_path: Path, as_json +): + """A verdict merged from several pages grades a connection the way the contract's own + verifier grades it over the same range: the weakest grade in effect over the period + covered, never the most recent one (CONSTITUTION.md article 7, « a verification verdict + carries, for every connection whose evidence it covers, the weakest grade in effect over + the period covered »). Both directions are asserted, because a merge that took the first + page's grade instead of the last would satisfy the upgrade case and lose the downgrade. + """ + chain = graded_chain( + ("rises", "unverified"), # page 1: the weakest grade of the range + ("falls", "evidence"), + ("rises", "observability"), # page 2: stronger later — must not lift the range + ("falls", "unverified"), # page 2: weaker later — must lower the range + ) + pages = [ + page_verified_over_its_own_range(chain[:2], from_sequence=1, next_from=3), + page_verified_over_its_own_range(chain[2:], from_sequence=3), + ] + contract = { + grade["connection_id"]: grade["grade"] + for grade in verify_chain(chain, scope="local", from_sequence=1).to_document()["grades"] + } + assert contract == {"falls": "unverified", "rises": "unverified"} + with answering_by_path(tmp_path / "d.sock", {"/scopes/": (200, pages)}) as address: + code, stdout, stderr = run("audit", *arguments(address, *(["--json"] if as_json else []))) + assert code == 0 + assert stderr == "" + if as_json: + rendered = { + grade["connection_id"]: grade["grade"] + for grade in json.loads(stdout)["result"]["served"]["grades"] + } + else: + rendered = dict( + line.removeprefix("grade: ").split(" ") + for line in stdout.splitlines() + if line.startswith("grade: ") + ) + assert rendered["rises"] == "unverified", "a later page's stronger grade lifted the range" + assert rendered["falls"] == "unverified", "a later page's weaker grade was dropped" + assert rendered == contract + + @pytest.mark.parametrize("as_json", [False, True]) @pytest.mark.parametrize("local_agrees", [True, False]) def test_audit_renders_a_gap_declared_on_an_earlier_page( @@ -289,14 +375,15 @@ def test_evidence_requires_an_implemented_subcommand(argv): "argv", [ ["history", "--socket", "d.sock", "--from", "1"], - ["history", "--scope", "local", "--from", "1"], ["history", "--scope", "local", "--socket", "d.sock"], ["audit", "--socket", "d.sock", "--from", "1"], - ["audit", "--scope", "local", "--from", "1"], ["audit", "--scope", "local", "--socket", "d.sock"], ], ) -def test_online_reads_require_scope_socket_and_from(argv): +def test_online_reads_require_scope_and_from(argv): + """The scope and the start of the range are never defaulted. The socket is no + longer in this list: a per-user profile given none is looked for at the + per-user default address, which `tests/test_default_socket.py` holds.""" with pytest.raises(SystemExit) as failure: main(["evidence", *argv]) assert failure.value.code == 2 diff --git a/tests/test_evidence_reads.py b/tests/test_evidence_reads.py index 9afc6a9..05321a5 100644 --- a/tests/test_evidence_reads.py +++ b/tests/test_evidence_reads.py @@ -5,6 +5,7 @@ import io import json +import os import pytest from documents import arguments, entry_lines, evidence_page, problem @@ -27,9 +28,9 @@ def replay(monkeypatch): def arrange(*responses): http = Replies(responses) - def connect(profile): + def connect(profile, **_): assert profile.scope == "local" - assert profile.socket_path == "daemon.sock" + assert profile.socket_path == os.path.abspath("daemon.sock") return verified(profile, http) monkeypatch.setattr(reads, "connect", connect) diff --git a/tests/test_exit_codes.py b/tests/test_exit_codes.py index 37c0e13..7cbbbd1 100644 --- a/tests/test_exit_codes.py +++ b/tests/test_exit_codes.py @@ -54,7 +54,7 @@ def test_no_outcome_takes_the_code_the_parser_uses() -> None: def test_the_codes_the_contract_already_published_are_not_renumbered() -> None: """Article 13: one contract, one rendering. - `sayfirst whoami` — implemented in the contract distribution — already + `sayfirstd whoami` — implemented in the contract distribution — already publishes these three codes for these three situations. A second client of the same contract that numbered them differently would make one event read two ways depending on which subcommand produced it. diff --git a/tests/test_follow_children.py b/tests/test_follow_children.py new file mode 100644 index 0000000..9ee07b7 --- /dev/null +++ b/tests/test_follow_children.py @@ -0,0 +1,404 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`instrument run --follow-children`: a governed program's Python children govern too. + +The engine installs the boundary in one process. A program that spawns another +Python — a worker, a step, a tool — starts a fresh interpreter the boundary is +not in, and its effects reach the world unasked. `--follow-children` puts the +same boundary in every Python child, before the child's code runs, by a +`sitecustomize` on its import path (`follow.py`). These cases read the wire: a +child that makes an effect is one more ask at the daemon when children are +followed, and none when they are not. + +The children here are `sys.executable -c …`, run in the interpreter these tests +run in, which has this client installed — the condition `--follow-children` +needs and `follow.py` states. A child that cannot install the boundary raises +out of `sitecustomize` and never runs ungoverned; that is the last case. +""" + +from __future__ import annotations + +import io +import json +import os +from pathlib import Path + +import pytest +from governed_programs import instrument, plant_spawn_pack, recording_daemon + +from sayfirst_cli.instrument import commands, follow, manifest + +#: A program that spawns a Python child which itself spawns a process. The +#: parent's spawn is governed by the engine in the parent; the child's spawn is +#: governed only if the child installed the boundary too. +PARENT = """\ +import subprocess +import sys + +subprocess.run( + [sys.executable, "-c", "import subprocess; subprocess.run(['true'], check=True)"], + check=True, +) +print("parent done") +""" + + +def _tree(tmp_path: Path) -> Path: + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text(PARENT, encoding="utf-8") + return tree + + +def _asks_for(asks: list[dict[str, object]], capability: str) -> int: + return sum(1 for ask in asks if ask.get("capability") == capability) + + +def test_the_child_is_ungoverned_without_the_flag(tmp_path: Path) -> None: + """The measurement the flag changes: the child's spawn asks nothing on its own.""" + tree = _tree(tmp_path) + pack = plant_spawn_pack(tmp_path) + with recording_daemon(tmp_path / "d.sock") as (socket_path, asks): + finished = instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--", "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert "parent done" in finished.stdout + # Only the parent's spawn of the child was asked about. + assert _asks_for(asks, "process.spawn") == 1 + + +def test_a_followed_child_governs_its_own_effect(tmp_path: Path) -> None: + """With the flag, the child installs the boundary and asks before it spawns.""" + tree = _tree(tmp_path) + pack = plant_spawn_pack(tmp_path) + with recording_daemon(tmp_path / "d.sock") as (socket_path, asks): + finished = instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--follow-children", "--", "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert "parent done" in finished.stdout + # The parent's spawn of the child, and the child's own spawn: two asks. + assert _asks_for(asks, "process.spawn") == 2 + + +def test_a_grandchild_is_followed_too(tmp_path: Path) -> None: + """The environment is left in place, so a child of a child installs it as well.""" + tree = tmp_path / "tree" + tree.mkdir() + grandchild = "import subprocess; subprocess.run(['true'], check=True)" + child = ( + f"import subprocess, sys; " + f"subprocess.run([sys.executable, '-c', {grandchild!r}], check=True)" + ) + (tree / "app.py").write_text( + f"import subprocess, sys\nsubprocess.run([sys.executable, '-c', {child!r}], check=True)\n", + encoding="utf-8", + ) + pack = plant_spawn_pack(tmp_path) + with recording_daemon(tmp_path / "d.sock") as (socket_path, asks): + finished = instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--follow-children", "--", "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + # Parent spawns child, child spawns grandchild, grandchild spawns `true`: three. + assert _asks_for(asks, "process.spawn") == 3 + + +def _child_environment(config: str) -> dict[str, str]: + import os + + return { + **dict(os.environ), + "PYTHONPATH": f"{follow.bootstrap_path()}:{Path(__file__).resolve().parents[1] / 'src'}", + follow.CONFIG_VARIABLE: config, + } + + +def test_a_followed_childs_effect_fails_closed_when_the_daemon_is_absent(tmp_path: Path) -> None: + """The boundary installs (a connection is opened at the ask, not before), and the + effect then fails closed at the absent daemon — never made, never ungoverned.""" + import subprocess + import sys + + pack = plant_spawn_pack(tmp_path) + config = follow.configuration( + [manifest.read_pack(pack)], + socket=str(tmp_path / "absent.sock"), + scope="local", + mode="per_user", + daemon_user=None, + principal=None, + ) + child = tmp_path / "child.py" + marker = tmp_path / "ran" + child.write_text(f"import subprocess\nsubprocess.run(['touch', {str(marker)!r}])\n", "utf-8") + finished = subprocess.run( + [sys.executable, str(child)], + env=_child_environment(config), + capture_output=True, + text=True, + timeout=60, + ) + assert finished.returncode != 0 + assert "could not ask" in finished.stderr + assert not marker.exists(), "the child made its effect despite the daemon being absent" + + +def test_a_followed_child_that_cannot_install_the_boundary_never_runs(tmp_path: Path) -> None: + """A configuration naming a pack that will not read: sitecustomize raises before + the child's program runs, so nothing of it executes.""" + import subprocess + import sys + + # A configuration whose pack directory holds no manifest: the child's + # `read_pack` refuses it, and sitecustomize turns that into a SystemExit. + config = json.dumps( + { + "packs": [str(tmp_path / "not-a-pack")], + "socket": "/s.sock", + "scope": "local", + "mode": "per_user", + "daemon_user": None, + "principal": None, + } + ) + child = tmp_path / "child.py" + marker = tmp_path / "ran" + child.write_text(f"open({str(marker)!r}, 'w').close()\n", "utf-8") + finished = subprocess.run( + [sys.executable, str(child)], + env=_child_environment(config), + capture_output=True, + text=True, + timeout=60, + ) + assert finished.returncode != 0 + assert "could not install the boundary" in finished.stderr + assert not marker.exists(), "the child ran despite the boundary not installing" + + +def test_verify_does_not_offer_follow_children() -> None: + """The flag is run's: verify proves one process, and a spawned child is one it + cannot see (`harness.py`), so following would govern what the proof misses.""" + # argparse refuses an unknown option by raising SystemExit(2); the flag is on + # `run`'s parser, not `verify`'s. + with pytest.raises(SystemExit) as refusal: + commands.main( + [ + "verify", + "--pack", + "subprocess", + "--scope", + "local", + "--follow-children", + "--", + "app.py", + ], + out=io.StringIO(), + err=io.StringIO(), + ) + assert refusal.value.code == 2 + + +def test_the_configuration_carries_what_a_child_rebuilds_the_run_from(tmp_path: Path) -> None: + pack = plant_spawn_pack(tmp_path) + config = json.loads( + follow.configuration( + [manifest.read_pack(pack)], + socket="/s.sock", + scope="local", + mode="per_user", + daemon_user=None, + principal="user:someone", + ) + ) + assert config == { + "packs": [str(pack)], + "socket": "/s.sock", + "scope": "local", + "mode": "per_user", + "daemon_user": None, + "principal": "user:someone", + } + + +def test_the_environment_puts_the_bootstrap_first_and_keeps_what_was_there(tmp_path: Path) -> None: + pack = plant_spawn_pack(tmp_path) + env = follow.environment_for( + {"PYTHONPATH": "/existing", "PYTHONSAFEPATH": "1"}, + [manifest.read_pack(pack)], + socket="/s.sock", + scope="local", + mode="per_user", + daemon_user=None, + principal=None, + ) + head, _, rest = env["PYTHONPATH"].partition(":") + assert head == str(follow.bootstrap_path()) + assert rest == "/existing" + # A safe path keeps the script's directory off the head of the path; it does + # not stop a `sitecustomize` on `PYTHONPATH` from loading, so it is the + # person's setting to keep, not this flag's to remove. + assert env["PYTHONSAFEPATH"] == "1" + assert follow.CONFIG_VARIABLE in env + + +def test_a_safe_path_still_follows_a_child(tmp_path: Path) -> None: + """`PYTHONSAFEPATH` set for the whole run: the child still installs the boundary. + + The flag used to claim it cleared the variable because a safe path would + keep the child from importing the bootstrap; neither half was true. The + variable was never removed from the program's environment, and a safe path + does not stop a `sitecustomize` on `PYTHONPATH` from loading — measured + here as the child's own ask arriving at the daemon. + """ + tree = _tree(tmp_path) + pack = plant_spawn_pack(tmp_path) + with recording_daemon(tmp_path / "d.sock") as (socket_path, asks): + finished = instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--follow-children", "--", "app.py", cwd=tree, + env={**os.environ, "PYTHONSAFEPATH": "1"}, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert "parent done" in finished.stdout + assert _asks_for(asks, "process.spawn") == 2 + + +def test_a_child_given_an_import_path_of_its_own_is_not_followed(tmp_path: Path) -> None: + """The documented limit: a child whose environment REPLACES `PYTHONPATH` — + `env={**os.environ, "PYTHONPATH": …}` — never sees the bootstrap on it, so it + runs as it would without the flag. The configuration variable is still there; + what is gone is the one entry that installs anything. Pinned, so the texts + that say what the flag covers stay narrower than « every Python child ».""" + tree = tmp_path / "tree" + tree.mkdir() + marker = tree / "own-path-child-ran" + child = f"import subprocess; subprocess.run(['touch', {str(marker)!r}], check=True)" + (tree / "app.py").write_text( + "import os, subprocess, sys\n" + f"subprocess.run([sys.executable, '-c', {child!r}], check=True,\n" + f" env={{**os.environ, 'PYTHONPATH': {str(tree)!r}}})\n", + encoding="utf-8", + ) + pack = plant_spawn_pack(tmp_path) + with recording_daemon(tmp_path / "d.sock") as (socket_path, asks): + finished = instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--follow-children", "--", "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert _asks_for(asks, "process.spawn") == 1 + assert marker.exists() + + +# -- review findings: relative packs, isolated children, prior sitecustomize ------ + + +def test_a_relative_pack_is_resolved_for_a_child_in_another_cwd(tmp_path: Path) -> None: + """A `--pack ./own-pack` designated in the parent's cwd reaches a child spawned + in a different cwd: the directory is made absolute before it is handed on, so + the child does not read `/own-pack` and die.""" + tree = tmp_path / "tree" + tree.mkdir() + (tree / "worker").mkdir() + plant_spawn_pack(tree, name="own-pack") + child = "import subprocess; subprocess.run(['true'], check=True)" + (tree / "app.py").write_text( + "import subprocess, sys\n" + f"subprocess.run([sys.executable, '-c', {child!r}], check=True, cwd='worker')\n", + encoding="utf-8", + ) + with recording_daemon(tmp_path / "d.sock") as (socket_path, asks): + finished = instrument( + "run", "--pack", "./own-pack", "--socket", str(socket_path), "--scope", "local", + "--follow-children", "--", "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + # Parent spawns the child, and the child (in ./worker) governs its own spawn: + # two asks, which only happens if the child found the pack by its resolved path. + assert _asks_for(asks, "process.spawn") == 2 + + +def test_a_child_started_isolated_is_not_followed(tmp_path: Path) -> None: + """The documented limit: a child started with `-S` (or `-I`/`-E`) does not import + the bootstrap and so is not followed — it runs as it would without the flag, + never ungoverned-by-a-broken-install. Pinned so it stays a known limit.""" + tree = tmp_path / "tree" + tree.mkdir() + marker = tree / "isolated-child-ran" + child = f"import subprocess; subprocess.run(['touch', {str(marker)!r}], check=True)" + (tree / "app.py").write_text( + "import subprocess, sys\n" + f"subprocess.run([sys.executable, '-S', '-c', {child!r}], check=True)\n", + encoding="utf-8", + ) + pack = plant_spawn_pack(tmp_path) + with recording_daemon(tmp_path / "d.sock") as (socket_path, asks): + finished = instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--follow-children", "--", "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + # Only the parent's spawn of the isolated child was asked about; the child's + # own spawn ran without the boundary, because -S skipped the bootstrap. + assert _asks_for(asks, "process.spawn") == 1 + assert marker.exists() # the isolated child ran, as it would without the flag + + +def _run_child_with_prior(tmp_path: Path, prior_dir: Path) -> tuple[int, str, str]: + """Run a child with the bootstrap AND a prior sitecustomize on the path, and NO + follow configuration — so the bootstrap only chains, and the chaining is what is + under test (no daemon needed).""" + import subprocess + import sys + + child = tmp_path / "child.py" + child.write_text("print('child ran')\n", encoding="utf-8") + environment = { + "PATH": os.environ.get("PATH", ""), + "PYTHONPATH": os.pathsep.join( + [ + str(follow.bootstrap_path()), + str(prior_dir), + str(Path(__file__).resolve().parents[1] / "src"), + ] + ), + } + finished = subprocess.run( + [sys.executable, str(child)], env=environment, capture_output=True, text=True, timeout=60 + ) + return finished.returncode, finished.stdout, finished.stderr + + +def test_a_prior_sitecustomize_file_with_a_dataclass_still_runs(tmp_path: Path) -> None: + """Chaining registers the prior module through import resolution, so code that + looks itself up in sys.modules — a dataclass with postponed annotations — works.""" + prior = tmp_path / "priordir" + prior.mkdir() + (prior / "sitecustomize.py").write_text( + "from __future__ import annotations\n" + "from dataclasses import dataclass\n" + "@dataclass\n" + "class Held:\n" + " value: int\n" + "print('prior ran')\n", + encoding="utf-8", + ) + code, out, err = _run_child_with_prior(tmp_path, prior) + assert code == 0, err + assert "prior ran" in out and "child ran" in out, (out, err) + + +def test_a_prior_sitecustomize_package_still_runs(tmp_path: Path) -> None: + """A `sitecustomize` PACKAGE (not a .py file) is found too, because chaining goes + through Python's own import resolution rather than a literal file scan.""" + prior = tmp_path / "priordir" + (prior / "sitecustomize").mkdir(parents=True) + (prior / "sitecustomize" / "__init__.py").write_text("print('prior ran')\n", encoding="utf-8") + code, out, err = _run_child_with_prior(tmp_path, prior) + assert code == 0, err + assert "prior ran" in out and "child ran" in out, (out, err) diff --git a/tests/test_http_client_pack.py b/tests/test_http_client_pack.py index 75efa19..450b3ba 100644 --- a/tests/test_http_client_pack.py +++ b/tests/test_http_client_pack.py @@ -14,7 +14,10 @@ The one thing this pack needs that `subprocess`'s did not: something to open a connection to. Every granted-path test here talks to a `http.server` this file -starts itself, on loopback, so nothing here reaches the network. +starts itself, on loopback, so nothing here reaches the network. The redirect +tests at the end do not even do that: a redirect needs an answer and not a +connection, so they supply the `302` and the `200` from a handler of their own, +in memory, and no socket is opened at all. The doubles all three of these suites drive their packs against — `FakeBoundary`, `RefusingBoundary`, `Spy` and the engine harness — are in `tests/pack_doubles.py`, @@ -27,19 +30,26 @@ import functools import hashlib import http.client +import io import threading import urllib.request from collections.abc import Callable, Iterator from contextlib import contextmanager +from email.message import Message from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path +from urllib.response import addinfourl import pytest +from canned_daemon import answering_by_path +from documents import no_evidence_page, recorded_effect_page from governed_programs import instrument, recording_daemon -from pack_doubles import FakeBoundary, RefusingBoundary, engine_fixture, installed +from pack_doubles import FakeBoundary, Handle, RefusingBoundary, engine_fixture, installed from pack_doubles import governed_by as _governed_by from sayfirst_boundary import AskRefused, Denied, Suspended +from sayfirst_boundary.digest import arguments_digest +from sayfirst_cli import exit_codes from sayfirst_cli.instrument import manifest from sayfirst_cli.instrument.engine import Engine @@ -51,6 +61,11 @@ #: not whatever `urlopen` happens to be bound to while it is installed. REAL_URLOPEN = urllib.request.urlopen +#: The opener's own `open`, captured for the same reason: the pack governs it +#: for the length of a governed call, and « it was put back » has to be checked +#: against the object that was there before any of this ran. +REAL_OPEN = urllib.request.OpenerDirector.open + # --- read_pack accepts the real pack ---------------------------------------- @@ -292,3 +307,257 @@ def test_a_governed_program_asks_net_egress_with_this_packs_capability( assert finished.returncode == 0, (finished.stdout, finished.stderr) assert len(asks) == 1, asks assert asks[0]["capability"] == "net.egress" + + +# --- a redirect makes a second request, and it is asked about too --------- + + +#: The two addresses of one ordinary redirect. `.invalid` is reserved by +#: RFC 2606 and resolves nowhere, which is a second guard beside the handler +#: below: nothing here can reach a network even if the handler were bypassed. +FIRST = "http://redirect.invalid/first" +SECOND = "http://redirect.invalid/second" + + +class InMemoryRedirect(urllib.request.HTTPHandler): + """A `302` at `/first` and a `200` at `/second`, answered without a socket. + + `urllib`'s own redirect machinery runs in full over these two answers — + `HTTPErrorProcessor` sees the `302`, hands it to `HTTPRedirectHandler`, and + that handler makes the second request through its opener. That second + request is the effect this section is about, and it is real: the + interpreter reports a second `urllib.Request` for it, which is the event + the verifier watches. + """ + + def http_open(self, request: urllib.request.Request) -> addinfourl: + headers = Message() + code = 200 + if request.full_url == FIRST: + headers["Location"] = SECOND + code = 302 + answer = addinfourl(io.BytesIO(b""), headers, request.full_url, code) + answer.msg = "in memory" + return answer + + +@contextmanager +def redirecting_opener() -> Iterator[None]: + """Install the in-memory opener, and put back whatever was there before. + + Installed BEFORE the engine, deliberately: that is the order the + reproduction of this defect used, and the order a program that built its + opener during its own imports produces. A fix that only governed openers + built after installation would pass with the two lines the other way round + and leave the reported defect in place. + """ + previous = urllib.request._opener + urllib.request.install_opener(urllib.request.build_opener(InMemoryRedirect())) + try: + yield + finally: + urllib.request._opener = previous + + +def test_the_second_request_a_redirect_makes_is_asked_about_too(engine: Engine) -> None: + """One `urlopen`, two requests, two asks — in the order they were made. + + `urlopen` is wrapped once, but a redirect is not a second call to it: the + redirect handler goes back through its opener. So a pack that asked only + where `urlopen` was called asked about the first address and let the second + request leave the process with no question put about it at all. + """ + with redirecting_opener(): + boundary = installed(engine, PACK) + urllib.request.urlopen(FIRST).close() + assert boundary.asked == [ + ("net.egress", {"url": FIRST}), + ("net.egress", {"url": SECOND}), + ] + + +def test_a_refused_redirect_never_makes_the_second_request(engine: Engine) -> None: + """Anti-vacuity for the ask above: the second ask is a real gate, not a note. + + The boundary grants the first request and refuses the second, and the + second address is never opened — read off the handler's own record of what + it was asked for, so « it did not happen » is measured rather than inferred. + """ + opened: list[str] = [] + + class Recording(InMemoryRedirect): + def http_open(self, request: urllib.request.Request) -> addinfourl: + opened.append(request.full_url) + return super().http_open(request) + + class GrantsThenRefuses: + """`FakeBoundary`'s shape, with the second answer a refusal. + + Not one of the shared doubles: neither of them refuses only after + granting, and that is exactly the sequence a governed redirect needs. + """ + + def __init__(self) -> None: + self.asked: list[tuple[str, dict[str, object]]] = [] + self.refusal = Denied( + decision_ref="d-redirect", capability="net.egress", reason="the plane said no" + ) + + @contextmanager + def request( + self, capability: str, arguments: dict[str, object], *, scope: str = "local" + ) -> Iterator[Handle]: + self.asked.append((capability, dict(arguments))) + if len(self.asked) > 1: + raise self.refusal + yield Handle() + + boundary = GrantsThenRefuses() + with redirecting_opener(): + previous = urllib.request._opener + urllib.request.install_opener(urllib.request.build_opener(Recording())) + try: + engine.install([manifest.read_pack(PACK)], boundary, scope="local") + with pytest.raises(Denied) as raised: + urllib.request.urlopen(FIRST) + finally: + urllib.request._opener = previous + assert raised.value is boundary.refusal + assert [address for _, address in ((c, a["url"]) for c, a in boundary.asked)] == [FIRST, SECOND] + assert opened == [FIRST], "the refused redirect opened the second address anyway" + + +#: The same redirect as a program, for the runs that go through a real process. +REDIRECT_PROGRAM = f"""\ +import io +import urllib.request +from email.message import Message +from urllib.response import addinfourl + +FIRST = {FIRST!r} +SECOND = {SECOND!r} + + +class InMemory(urllib.request.HTTPHandler): + def http_open(self, request): + headers = Message() + code = 200 + if request.full_url == FIRST: + headers["Location"] = SECOND + code = 302 + answer = addinfourl(io.BytesIO(b""), headers, request.full_url, code) + answer.msg = "in memory" + return answer + + +urllib.request.install_opener(urllib.request.build_opener(InMemory())) +urllib.request.urlopen(FIRST).close() +print("followed the redirect") +""" + + +def a_redirecting_tree(root: Path) -> Path: + tree = root / "redirect" + tree.mkdir() + (tree / "app.py").write_text(REDIRECT_PROGRAM, encoding="utf-8") + return tree + + +def test_a_governed_redirect_puts_both_addresses_on_the_wire(tmp_path: Path) -> None: + """The shipped pack, the real console script, a real daemon reading the wire.""" + with recording_daemon(tmp_path / "d.sock") as (socket_path, asks): + finished = instrument( + "run", + "--pack", + str(PACK), + "--socket", + str(socket_path), + "--scope", + "local", + "--", + "app.py", + cwd=a_redirecting_tree(tmp_path), + ) + assert finished.returncode == 0, (finished.stdout, finished.stderr) + assert [ask["capability"] for ask in asks] == ["net.egress", "net.egress"], asks + # What crosses the wire is the digest and never the address (article 11), + # so which ADDRESS each ask was about is read by digesting the two the + # program used with the boundary's own recipe. + assert [ask["arguments_digest"] for ask in asks] == [ + arguments_digest({"url": FIRST}), + arguments_digest({"url": SECOND}), + ], asks + + +def test_the_verifier_needs_a_decision_for_each_of_the_redirects_two_requests( + tmp_path: Path, +) -> None: + """What the shipped verifier asks of an ordinary redirect: two records. + + The pair this test and the one above form is the whole of finding F11. The + verifier counts the interpreter's own `urllib.Request` events and consults + the chain once for each, so a one-hop redirect is `governed` only against + two recorded decisions and is a finding against one. The pack above now + produces exactly those two, so the two shipped halves agree about a + redirect instead of contradicting each other. + """ + routes = {"/scopes/": (200, [no_evidence_page(), recorded_effect_page("net.egress", count=2)])} + with answering_by_path(tmp_path / "two.sock", routes) as address: + code, stdout, stderr = _verify(address, a_redirecting_tree(tmp_path)) + assert code == 0, (stdout, stderr) + assert "governed http-client urllib.request.urlopen net.egress events=2" in stdout + assert "followed the redirect" in stderr + + one = tmp_path / "one" + one.mkdir() + routes = {"/scopes/": (200, [no_evidence_page(), recorded_effect_page("net.egress", count=1)])} + with answering_by_path(one / "one.sock", routes) as address: + code, stdout, stderr = _verify(address, a_redirecting_tree(one)) + assert code == exit_codes.EXIT_CHECK_FAILED, (stdout, stderr) + assert "ungoverned http-client urllib.request.urlopen net.egress events=1" in stdout + + +def _verify(address: Path, tree: Path) -> tuple[int, str, str]: + """`sayfirst instrument verify --ungoverned`, the way a person runs it.""" + finished = instrument( + "verify", + "--pack", + str(PACK), + "--socket", + str(address), + "--scope", + "local", + "--ungoverned", + "--", + "app.py", + cwd=tree, + ) + return finished.returncode, finished.stdout, finished.stderr + + +def test_the_opener_is_governed_only_while_a_call_is_in_flight(engine: Engine) -> None: + """The reach of the fix, both ends of it: in place during, put back after. + + Read from inside the handler, which the opener calls while it is running, + so « governed during the call » is observed rather than assumed — and read + again afterwards against the object captured before any test ran, so + « put back » is the very object and not one that resembles it. + """ + during: list[object] = [] + + class Observing(InMemoryRedirect): + def http_open(self, request: urllib.request.Request) -> addinfourl: + during.append(urllib.request.OpenerDirector.open) + return super().http_open(request) + + previous = urllib.request._opener + urllib.request.install_opener(urllib.request.build_opener(Observing())) + try: + installed(engine, PACK) + assert urllib.request.OpenerDirector.open is REAL_OPEN, "governed before any call" + urllib.request.urlopen(FIRST).close() + finally: + urllib.request._opener = previous + assert len(during) == 2, during + assert REAL_OPEN not in during, "the redirect was followed through the ungoverned open" + assert urllib.request.OpenerDirector.open is REAL_OPEN, "the opener was left governed" diff --git a/tests/test_instrument_run.py b/tests/test_instrument_run.py index bb488a3..1ebdae6 100644 --- a/tests/test_instrument_run.py +++ b/tests/test_instrument_run.py @@ -18,11 +18,15 @@ from __future__ import annotations +import atexit import io import os +import sys from pathlib import Path import pytest +from canned_daemon import answering_by_path +from documents import no_evidence_page from governed_programs import ( HELPER, SIBLING_MODULE_APP, @@ -37,7 +41,7 @@ from sayfirst_contract_stub.stub_http import serve from sayfirst_cli import exit_codes -from sayfirst_cli.instrument import commands +from sayfirst_cli.instrument import commands, launch def refuse(*argv: str) -> tuple[int, str, str]: @@ -445,6 +449,227 @@ def test_the_argv_and_the_path_entry_are_the_programs_own(tmp_path: Path) -> Non assert f"head {tree}" in finished.stdout +# --- The program's lifecycle does not end when its main module returns --------- + + +#: A program that leaves work behind it. Its handler reads the two things the +#: interpreter hands a program — the arguments it was given and the directory +#: its own modules are found on — and it runs after the main module has +#: returned, which is the whole point of it. +EXIT_HANDLER_APP = """\ +import atexit +import sys + + +def at_exit(): + print("at exit argv", sys.argv) + print("at exit head", sys.path[0]) + import helper + + print("at exit helper", helper.NAME) + + +atexit.register(at_exit) +print("in main argv", sys.argv) +""" + + +def a_tree_whose_exit_handler_reads_its_own_state(root: Path, name: str = "tree") -> Path: + """The two-file program again, with the import moved into the exit handler. + + Two files rather than one for the same reason the sibling tests give: a + main script is found by name and never imported, so only a second file can + say whether the import path the handler runs on is the program's own. + """ + tree = root / name + tree.mkdir(parents=True) + (tree / "app.py").write_text(EXIT_HANDLER_APP, encoding="utf-8") + (tree / "helper.py").write_text(HELPER, encoding="utf-8") + return tree + + +def test_an_exit_handler_sees_the_programs_own_argv_and_imports_its_sibling( + tmp_path: Path, +) -> None: + """A handler registered by the program is the program, and gets what the program gets. + + Measured as the defect: the hand-off put the launcher's `sys.argv` and + `sys.path` back as soon as the main module RETURNED, which is not when the + program ends. The handler then read the `sayfirst` command line as its own + arguments and could not import the module sitting beside it — an ordinary + program changed by being governed, without ever asking the boundary. + """ + tree = a_tree_whose_exit_handler_reads_its_own_state(tmp_path) + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "run", + "--pack", + str(pack), + "--socket", + str(tmp_path / "absent.sock"), + "--scope", + "local", + "--", + "app.py", + "one", + cwd=tree, + ) + assert finished.returncode == 0, (finished.stdout, finished.stderr) + # Anti-vacuity: the main module read its own arguments before the defect + # and reads them after it, so a test asserting only the second line would + # pass on a hand-off that gave the program nothing at all. + assert "in main argv ['app.py', 'one']" in finished.stdout + assert "at exit argv ['app.py', 'one']" in finished.stdout, finished.stdout + assert f"at exit head {tree}" in finished.stdout, finished.stdout + assert "at exit helper the-sibling" in finished.stdout, (finished.stdout, finished.stderr) + assert "Traceback" not in finished.stderr + + +#: The other thing that outlives a main module: a thread the program started +#: and did not join. It reads what the handler reads, half a second after the +#: main module's last statement, which is the only way this moment can be +#: timed from inside the program. +LATE_THREAD_APP = """\ +import sys +import threading +import time + + +def late(): + time.sleep(0.5) + print("in thread argv", sys.argv) + import helper + + print("in thread helper", helper.NAME) + + +threading.Thread(target=late).start() +print("in main argv", sys.argv) +""" + + +def test_a_thread_that_outlives_the_main_module_keeps_the_programs_own_state( + tmp_path: Path, +) -> None: + """A thread the interpreter will wait for is the program too, and gets what it gets. + + The same defect as the exit handler's and the same fix: the launcher's + state went back the instant the main module returned, and a thread still + running read the `sayfirst` command line as its arguments. The half second + is what makes the thread late rather than concurrent — it is the one thing + here that can only be timed — and a thread that ran early would read the + right arguments for the wrong reason, which is why the run above it asserts + the same claim at a moment that needs no clock. + """ + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text(LATE_THREAD_APP, encoding="utf-8") + (tree / "helper.py").write_text(HELPER, encoding="utf-8") + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "run", + "--pack", + str(pack), + "--socket", + str(tmp_path / "absent.sock"), + "--scope", + "local", + "--", + "app.py", + "one", + cwd=tree, + ) + assert finished.returncode == 0, (finished.stdout, finished.stderr) + assert "in thread argv ['app.py', 'one']" in finished.stdout, finished.stdout + assert "in thread helper the-sibling" in finished.stdout, (finished.stdout, finished.stderr) + + +def test_the_verified_hand_off_leaves_the_exit_handler_the_programs_own_state( + tmp_path: Path, +) -> None: + """The same claim on the path that runs the handlers itself, which is later still. + + `verify` does not leave the exit handlers to the interpreter: the harness + runs them while its watch is still armed, after the hand-off has already + returned. So the restore has to outlast that too, and a fix that only + worked on the plain `run` path would pass the test above and fail here. + + Ungoverned, and against a chain holding nothing: this test is about what + the program sees, not about a verdict, and the program asks the boundary + for nothing. Its output reaches the error stream because that is where + `verify` puts the program's own, keeping stdout for the answer. + """ + tree = a_tree_whose_exit_handler_reads_its_own_state(tmp_path) + pack = plant_spawn_pack(tmp_path) + routes = {"/scopes/": (200, no_evidence_page())} + with answering_by_path(tmp_path / "d.sock", routes) as address: + finished = instrument( + "verify", + "--pack", + str(pack), + "--socket", + str(address), + "--scope", + "local", + "--ungoverned", + "--", + "app.py", + "one", + cwd=tree, + ) + assert "in main argv ['app.py', 'one']" in finished.stderr + assert "at exit argv ['app.py', 'one']" in finished.stderr, finished.stderr + assert f"at exit head {tree}" in finished.stderr, finished.stderr + assert "at exit helper the-sibling" in finished.stderr, (finished.stdout, finished.stderr) + + +def test_the_launchers_own_state_comes_back_once_the_program_is_done( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Deferred is not abandoned: the last thing the register holds is the restore. + + The other half of the fix, and the half a `run` through a process cannot + show, because the process ends on the same breath. `atexit` runs its + register last in, first out, so the restore is registered BEFORE the + program starts and every handler the program registers afterwards runs + ahead of it. That order is what this asserts, by standing in for the + register: the handlers are replayed the way the interpreter replays them, + and the launcher's own arguments and import path are back when the last + one has run. + + In process, and against a stand-in for the register, because running the + real one here would run this test session's own handlers. The stand-in is + the register in both the ways the launcher uses it — what is added to it, + and how many things it holds — and the replay is the interpreter's own + order; nothing else about it is borrowed. + """ + registered: list[object] = [] + + def register(function: object) -> object: + registered.append(function) + return function + + monkeypatch.setattr(atexit, "register", register) + monkeypatch.setattr(atexit, "_ncallbacks", lambda: len(registered)) + tree = a_tree_whose_exit_handler_reads_its_own_state(tmp_path, name="in-process") + monkeypatch.syspath_prepend(str(tree)) + mine_argv, mine_path = list(sys.argv), list(sys.path) + try: + launch.hand_over([str(tree / "app.py"), "one"], err=io.StringIO()) + # The program is not done, so its own state still stands. + assert sys.argv == [str(tree / "app.py"), "one"], sys.argv + # Last in, first out: the program's handler, and then the restore. + for callback in reversed(registered): + callback() # type: ignore[operator] + after_argv, after_path = list(sys.argv), list(sys.path) + finally: + sys.argv[:] = mine_argv + sys.path[:] = mine_path + sys.modules.pop("helper", None) + assert after_argv == mine_argv + assert after_path == mine_path + + # --- Before the hand-off, every failure is this client's ------------------------ @@ -774,7 +999,11 @@ def test_a_missing_pack_directory_is_named_once_and_not_twice(tmp_path: Path) -> def test_a_package_without_a_main_is_a_misuse_not_a_traceback(tmp_path: Path) -> None: - """`-m json` names a package with no `__main__`: not a program to run, and not a denial.""" + """`-m email` names a package with no `__main__`: not a program to run, and not a denial. + + Not `json`, which was the example here until Python 3.14 gave it a `__main__` + — a package with none, on every interpreter this client supports, is `email`. + """ pack = plant_spawn_pack(tmp_path) _, _, err = misused( "run", @@ -786,10 +1015,10 @@ def test_a_package_without_a_main_is_a_misuse_not_a_traceback(tmp_path: Path) -> "local", "--", "-m", - "json", + "email", cwd=a_tree_with_a_quiet_program(tmp_path), ) - assert "no `__main__` in the package 'json'" in err + assert "no `__main__` in the package 'email'" in err assert "Traceback" not in err @@ -866,3 +1095,69 @@ def test_a_pack_naming_an_absent_attribute_on_a_later_import_is_a_misuse_not_a_t # the clause the test above this one covers, and this one would pass while # proving nothing about the clause it is named for. assert out == "the program started\n", "the program never ran, so this is the other clause" + + +# -- an outcome the program does not handle ends with the status this client publishes + + +def _run_under(tmp_path: Path, app: str, socket_path: Path) -> object: + tree = tmp_path / "tree" + tree.mkdir(exist_ok=True) + (tree / "app.py").write_text(app, encoding="utf-8") + pack = plant_spawn_pack(tmp_path) + return instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--", "app.py", cwd=tree, + ) # fmt: skip + + +def test_a_suspension_the_program_does_not_handle_ends_with_the_suspend_status( + tmp_path: Path, +) -> None: + """5, as `ask` says it — not the interpreter's 1, which is this client's « deny ».""" + stub = Stub("review_approve") + with serve(stub, tmp_path / "d.sock") as socket_path: + finished = _run_under(tmp_path, SPAWNING_APP, socket_path) + assert finished.returncode == exit_codes.EXIT_SUSPEND, finished.stderr + assert "Suspended" in finished.stderr and "Traceback" in finished.stderr + + +def test_a_control_plane_that_could_not_be_asked_is_not_a_denial(tmp_path: Path) -> None: + """Article 1: « could not ask » and « denied » never read as each other, in `$?` either.""" + finished = _run_under(tmp_path, SPAWNING_APP, tmp_path / "absent.sock") + assert finished.returncode == exit_codes.EXIT_COULD_NOT_ASK, finished.stderr + assert "CouldNotAsk" in finished.stderr + + +def test_an_unavailable_policy_is_could_not_ask_too(tmp_path: Path) -> None: + stub = Stub("policy_unavailable_is_could_not_ask") + with serve(stub, tmp_path / "d.sock") as socket_path: + finished = _run_under(tmp_path, SPAWNING_APP, socket_path) + assert finished.returncode == exit_codes.EXIT_COULD_NOT_ASK, finished.stderr + + +def test_an_outcome_the_program_handles_is_the_programs_own_ending(tmp_path: Path) -> None: + handled = ( + "import subprocess, sys\n" + "from sayfirst_boundary import Denied\n" + "try:\n" + ' subprocess.run(["true"], check=True)\n' + "except Denied:\n" + ' print("handled the denial")\n' + " sys.exit(0)\n" + ) + stub = Stub("deny") + with serve(stub, tmp_path / "d.sock") as socket_path: + finished = _run_under(tmp_path, handled, socket_path) + assert finished.returncode == 0, finished.stderr + assert "handled the denial" in finished.stdout + + +def test_every_boundary_outcome_has_its_published_status() -> None: + from sayfirst_boundary import AskRefused, CouldNotAsk, Denied, Suspended + + assert commands.ending_for(Denied(decision_ref="d", capability="c", reason="r")) == 1 + assert commands.ending_for(Suspended(approval_ref="a", decision_ref="d", capability="c")) == 5 + assert commands.ending_for(AskRefused(problem_code="p", detail="d")) == 3 + assert commands.ending_for(CouldNotAsk(detail="d", retryable=None)) == 4 + assert commands.ending_for(RuntimeError("the program's own")) is None diff --git a/tests/test_instrument_verify.py b/tests/test_instrument_verify.py index aebfac7..91b3d20 100644 --- a/tests/test_instrument_verify.py +++ b/tests/test_instrument_verify.py @@ -30,7 +30,7 @@ import pytest from canned_daemon import answering_by_path -from documents import foreign_generation_page, no_evidence_page, recorded_effect_page +from documents import foreign_generation_page, no_evidence_page, problem, recorded_effect_page from governed_programs import ( FIRST_ARGUMENT_INTERPOSE, SPAWNING_APP, @@ -333,6 +333,10 @@ def test_the_json_form_carries_the_report_and_what_was_verified(tmp_path: Path) "audit_event": "subprocess.Popen", "capability": SPAWN, "events": 1, + # Empty, and present: the count says how much this run could not + # judge and this says why, so a run with nothing outstanding + # publishes an empty list rather than leaving a reader to infer one. + "incomplete": [], "module": "subprocess", "pack": "process-effects", "unjudged": 0, @@ -1608,14 +1612,20 @@ def a_sourceless_module(root: Path, marker: Path) -> Path: def test_a_gate_that_never_opened_says_so_and_reports_no_verdict( tmp_path: Path, as_json: bool ) -> None: - """Exit 4 with the sentence, never `not-exercised`: nothing was watched. + """Exit 7 with the sentence, never `not-exercised`: nothing was watched. Measured as the defect: this run printed - `not-exercised … events=0` and exited 7 — byte for byte what a program that - never walked the path produces — while the spawn happened. « This run - established an absence » and « this run never began watching » are - different facts, and the rest of this harness is fastidious about exactly - that distinction. + `not-exercised … events=0` — byte for byte what a program that never walked + the path produces — while the spawn happened. « This run established an + absence » and « this run never began watching » are different facts, and + the rest of this harness is fastidious about exactly that distinction: the + sentence and the problem code say which one happened, and no verdict line is + printed at all. + + The status is 7, the local check that could not conclude — the plane was + asked, its chain was read, and what could not be done was the watching. It + was 4 for a while, which a shell reads as « the control plane could not be + asked » about a control plane that had answered. The spawn happening is asserted, not excused: it is why the run must report no verdict at all. @@ -1638,10 +1648,9 @@ def test_a_gate_that_never_opened_says_so_and_reports_no_verdict( "solo", cwd=tree, ) - assert code == exit_codes.EXIT_COULD_NOT_ASK, (stdout, stderr) - assert code != exit_codes.EXIT_COULD_NOT_CHECK - assert code != 0 - assert "not-exercised" not in stderr + assert code == exit_codes.EXIT_COULD_NOT_CHECK, (stdout, stderr) + assert code != exit_codes.EXIT_COULD_NOT_ASK + assert "not-exercised" 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 marker.exists(), "the program did not run, so this proves nothing about the gate" @@ -2088,3 +2097,278 @@ def test_findings_that_exist_and_will_not_read_answer_the_code_for_a_check( assert envelope["problem"]["message"] == verify_command.UNREADABLE_FINDINGS # And no path inside a directory this command has already removed. assert "sayfirst-verify-" not in envelope["problem"]["message"] + + +# --- a read the control plane refused is refused, not « could not ask » -------- + + +def test_a_chain_read_the_plane_refused_answers_refused(tmp_path: Path) -> None: + """3 and not 4: the plane was asked, and it said no. + + Every other command of this client answers a refused read with 3. `verify` + answered 4 for it — « the control plane could not be asked » — about a + control plane that had been asked and had answered. + """ + code, envelope = _answered( + _said( + tmp_path, + outcome=harness.CHAIN_UNREADABLE_BEFORE, + detail="the scope is not one the daemon reads", + problem_code="scope_invalid", + problem_class="refused", + ) + ) + assert code == exit_codes.EXIT_REFUSED + assert envelope["problem"]["code"] == "scope_invalid" + + +def test_the_class_the_harness_carried_decides_and_not_the_registry(tmp_path: Path) -> None: + """A problem this client minted is « could not ask », whatever class its code has. + + The transport mints `generation_unsupported`, which the registry classes as + refused, for an answer in a generation this client does not read: nobody + refused anything. The class travels in the outcome file for that reason. + """ + code, envelope = _answered( + _said( + tmp_path, + outcome=harness.CHAIN_UNREADABLE_DURING, + detail="an answer in another generation", + problem_code="generation_unsupported", + problem_class="could_not_ask", + ) + ) + assert code == exit_codes.EXIT_COULD_NOT_ASK + assert envelope["problem"]["code"] == "generation_unsupported" + + +@pytest.mark.parametrize("carried", [None, "declined", 3]) +def test_a_class_this_command_cannot_read_is_not_read_as_refused( + tmp_path: Path, carried: object +) -> None: + """An absent or unknown class is the fallback, never the more precise answer.""" + members: dict[str, object] = { + "outcome": harness.CHAIN_UNREADABLE_BEFORE, + "detail": "", + "problem_code": "scope_invalid", + } + if carried is not None: + members["problem_class"] = carried + code, _ = _answered(_said(tmp_path, **members)) + assert code == exit_codes.EXIT_COULD_NOT_ASK + + +def test_a_refused_class_on_an_ending_that_carries_no_read_changes_nothing( + tmp_path: Path, +) -> None: + """Only a chain read is classified; the harness's own endings keep their codes.""" + code, _ = _answered( + _said(tmp_path, outcome=harness.REPORTED, detail="", problem_class="refused") + ) + assert code == exit_codes.EXIT_COULD_NOT_CHECK + + +def test_a_refused_class_with_a_code_the_registry_does_not_know_is_not_refused( + tmp_path: Path, +) -> None: + """The fallback code is a « could not ask » code, and the status goes with it.""" + code, envelope = _answered( + _said( + tmp_path, + outcome=harness.CHAIN_UNREADABLE_BEFORE, + detail="", + problem_code="chain_went_sideways", + problem_class="refused", + ) + ) + assert code == exit_codes.EXIT_COULD_NOT_ASK + assert envelope["problem"]["code"] == verify_command.UNCLASSIFIED.value + + +@pytest.mark.parametrize( + ("answered", "expected"), + [(True, "refused"), (False, "could_not_ask")], +) +def test_the_harness_writes_the_class_of_the_problem_it_carries( + tmp_path: Path, answered: bool, expected: str +) -> None: + """Asked of the value: the same code is refused when the plane sent it and + « could not ask » when this client minted it for an answer it could not read.""" + from sayfirst_contract.problems import Problem, ProblemCode + + outcome = tmp_path / "outcome.json" + problem = Problem( + ProblemCode.GENERATION_UNSUPPORTED, + "another generation", + False, + 1, + control_plane_answered=answered, + ) + harness._write_outcome(outcome, harness.CHAIN_UNREADABLE_BEFORE, "d", problem=problem) + written = json.loads(outcome.read_text(encoding="utf-8")) + assert written["problem_code"] == "generation_unsupported" + assert written["problem_class"] == expected + + +def _verify_against(tmp_path: Path, pages: object) -> tuple[int, str, str]: + pack = plant_spawn_pack(tmp_path) + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text(SPAWNING_APP, encoding="utf-8") + with answering_by_path(tmp_path / "d.sock", {"/scopes/": (200, pages)}) as address: + return verify( + "--pack", + str(pack), + "--socket", + str(address), + "--scope", + "local", + "--ungoverned", + "--json", + "--", + "app.py", + cwd=tree, + ) + + +def test_verify_answers_refused_when_the_plane_refuses_the_first_read(tmp_path: Path) -> None: + code, stdout, stderr = _verify_against(tmp_path, [(403, problem("scope_refused"))]) + assert code == exit_codes.EXIT_REFUSED, (stdout, stderr) + assert json.loads(stdout)["problem"]["code"] == "scope_refused" + + +def test_verify_answers_refused_when_the_plane_refuses_a_read_while_the_program_runs( + tmp_path: Path, +) -> None: + code, stdout, stderr = _verify_against( + tmp_path, [no_evidence_page(), (403, problem("principal_refused"))] + ) + assert code == exit_codes.EXIT_REFUSED, (stdout, stderr) + assert json.loads(stdout)["problem"]["code"] == "principal_refused" + assert "while the program ran" in json.loads(stdout)["problem"]["message"] + + +def test_verify_still_answers_could_not_ask_when_the_plane_cannot_answer(tmp_path: Path) -> None: + """The other class, through the same path: a store that is down is not a refusal.""" + code, stdout, stderr = _verify_against( + tmp_path, [no_evidence_page(), (503, problem("evidence_store_unavailable"))] + ) + assert code == exit_codes.EXIT_COULD_NOT_ASK, (stdout, stderr) + assert json.loads(stdout)["problem"]["code"] == "evidence_store_unavailable" + + +# --- the program's diagnostics are not the report, and cannot discard it -------- + +#: A program that prints a byte no locale decodes, on both of its streams, and +#: then walks the governed path and ends normally. The byte is written to the +#: descriptor rather than through `print`, because a text stream would refuse +#: it here rather than in the parent — and the parent is where the defect was. +A_PROGRAM_THAT_PRINTS_A_BYTE = """\ +import os +import subprocess + +os.write(1, b"\\xff") +os.write(2, b"\\xfe") +subprocess.run(["true"], check=True) +""" + + +def test_a_byte_the_program_printed_never_discards_a_completed_verification( + tmp_path: Path, +) -> None: + """A target's diagnostics are decoded leniently; its verdict still arrives. + + Measured as the defect: the harness's two streams were captured with + strict locale decoding, so one byte of `0xff` from an arbitrary Python + program raised `UnicodeDecodeError` in the PARENT, before the report file + was read. A verification that had run to a conclusion — one spawn, one + recorded allow, `governed` written down — was thrown away by output that + said nothing about governance at all. + + The report does not travel on either of these streams: the harness writes + it to a file of its own (`harness._write_report`) and this command reads + that file, strictly, in `_findings`. So leniency here reaches diagnostics + only, and the test below holds the report's own channel to the opposite + rule. + """ + pack = plant_spawn_pack(tmp_path) + tree = a_quiet_tree(tmp_path, body=A_PROGRAM_THAT_PRINTS_A_BYTE) + routes = {"/scopes/": (200, [no_evidence_page(), recorded_effect_page(SPAWN)])} + with answering_by_path(tmp_path / "d.sock", routes) as address: + code, stdout, stderr = verify( + "--pack", + str(pack), + "--socket", + str(address), + "--scope", + "local", + "--ungoverned", + "--", + "app.py", + cwd=tree, + ) + assert code == 0, (stdout, stderr) + assert f"governed process-effects subprocess.Popen {SPAWN} events=1" in stdout + assert "target exit: 0" in stdout + assert "UnicodeDecodeError" not in stderr + # The bytes were not dropped either: both streams reached the diagnostic + # stream, each byte standing as the replacement character. + assert stderr.count("�") >= 2, stderr + + +def test_a_report_this_client_cannot_decode_is_an_inability_and_never_a_verdict( + tmp_path: Path, +) -> None: + """The report's own channel stays strict: a mangled report is no verdict. + + The twin of the test above, and the reason the leniency there is confined + to `_harness`. Replacement-decoding the findings would turn bytes nobody + wrote as a report into a document that might parse — a quietly wrong + verdict, which is worse than the crash being removed. `_findings` answers + « there are no findings », which `run` reports as a verification that + concluded nothing. + """ + report = tmp_path / verify_command.REPORT_FILE + report.write_bytes(b'{"points": [{"verdict": "governed\xff"}]}\n') + assert verify_command._findings(report) is None + + +def test_a_run_matches_records_by_its_correlation_not_by_a_connection() -> None: + """The fix for a multi-effect governed run: records span connections, one token. + + The shipped boundary holds one connection per grant, so a run that asks about + two kinds of effect writes two records on two connections. Both are this + run's, and the token this run stamped is what says so — the first record as + much as the second. + """ + chain = harness.Chain( + connection=None, # type: ignore[arg-type] + scope="local", + floor=0, + one_execution=True, + correlation="sayfirst-verify:the-run", + ) + mine_first = {"correlation": "sayfirst-verify:the-run", "connection_id": "c-1"} + mine_second = {"correlation": "sayfirst-verify:the-run", "connection_id": "c-2"} + another = {"correlation": "sayfirst-verify:another-run", "connection_id": "c-3"} + absent = {"connection_id": "c-4"} + # Both of this run's records match, though they name different connections; + # the first is held to the token, not trusted for naming it. + assert harness._this_runs_correlation(chain, mine_first) is True + assert harness._this_runs_correlation(chain, mine_second) is True + # Another execution's token, and a record carrying none, are not this run's. + assert harness._this_runs_correlation(chain, another) is False + assert harness._this_runs_correlation(chain, absent) is False + + +def test_an_ungoverned_run_requires_no_correlation() -> None: + """`--ungoverned` stamped nothing, so the chain alone answers and every record + is another execution's by construction.""" + chain = harness.Chain( + connection=None, # type: ignore[arg-type] + scope="local", + floor=0, + one_execution=False, + correlation=None, + ) + assert harness._this_runs_correlation(chain, {"correlation": None}) is True diff --git a/tests/test_interpreter_target.py b/tests/test_interpreter_target.py new file mode 100644 index 0000000..f33e354 --- /dev/null +++ b/tests/test_interpreter_target.py @@ -0,0 +1,596 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`-- python app.py`: the program runs under the interpreter a person named. + +A governed program runs inside the interpreter that carries this command. That +is right when the two are one environment, and it is a quiet lie the moment they +are not: installed as a tool of its own, this command's interpreter has none of +an application's dependencies, so a target spelled `python app.py` — the way +the person runs it every day — would either be refused, or, worse, be run by an +interpreter they did not name and fail on its first import. + +So the first word of a target may name an interpreter, and then the WHOLE +command is handed to that interpreter: same verb, same packs, same profile, the +word removed. The boundary is installed in that process before the program's +first import, exactly as it is in this one; the three distributions that make +the boundary are lent to it by location, and nothing else of this environment +is. + +These cases use a second environment that holds one module this one does not, +because « it ran under the named interpreter » has to be something a program +can only do there. +""" + +from __future__ import annotations + +import io +import os +import sys +import sysconfig +import venv +from pathlib import Path + +import pytest +from governed_programs import instrument, plain_environment, plant_spawn_pack +from sayfirst_contract_stub.stub import Stub +from sayfirst_contract_stub.stub_http import serve + +from sayfirst_cli import exit_codes +from sayfirst_cli.instrument import commands, interpreter + +#: A program only the second environment can run, which then makes one named +#: effect and says which interpreter it ran under and what it was handed. +APP = """\ +import importlib.util +import subprocess +import sys + +import only_in_the_application + +subprocess.run(["true"], check=True) +print("lent", importlib.util.find_spec("pytest") is not None) +print("prefix", sys.prefix) +print("argv", sys.argv) +print("head", sys.path[0]) +print("found", only_in_the_application.WHERE) +""" + + +@pytest.fixture(scope="module") +def application(tmp_path_factory: pytest.TempPathFactory) -> Path: + """A second environment, of this same Python, holding one module of its own.""" + root = tmp_path_factory.mktemp("application") / "env" + venv.create(root, with_pip=False, symlinks=True) + purelib = Path(sysconfig.get_path("purelib", vars={"base": str(root), "platbase": str(root)})) + (purelib / "only_in_the_application.py").write_text('WHERE = "application"\n', "utf-8") + assert (root / "bin" / "python").exists() + return root + + +def _tree(tmp_path: Path) -> Path: + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text(APP, encoding="utf-8") + return tree + + +@pytest.mark.parametrize( + ("target", "named"), + [ + (["python", "app.py"], "python"), + (["python3", "-m", "pkg"], "python3"), + (["python3.13", "app.py"], "python3.13"), + (["/usr/bin/python3", "app.py"], "/usr/bin/python3"), + (["./env/bin/python", "app.py"], "./env/bin/python"), + (["app.py"], None), + (["-m", "pkg"], None), + (["python.py"], None), + (["pythonic"], None), + (["tools/python-report.py"], None), + ([], None), + ], +) +def test_an_interpreter_is_recognised_by_the_spelling_of_the_first_word( + target: list[str], named: str | None +) -> None: + assert interpreter.named_in(target) == named + + +def test_the_program_runs_under_the_interpreter_that_was_named( + application: Path, tmp_path: Path +) -> None: + tree = _tree(tmp_path) + pack = plant_spawn_pack(tmp_path) + stub = Stub("allow") + with serve(stub, tmp_path / "d.sock") as socket_path: + finished = instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--", str(application / "bin" / "python"), "app.py", "--flag", "value", + cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + said = dict(line.split(" ", 1) for line in finished.stdout.splitlines()) + assert said["found"] == "application" + assert Path(said["prefix"]) == application + # `pytest` is installed beside this command and not in the application's + # environment, and it stays that way: what is lent is three packages by + # name, never the directory they happen to be installed in. + assert said["lent"] == "False" + # What the interpreter would have handed the program for `python app.py …`: + # its own arguments, and its own directory at the head of the import path. + assert said["argv"] == "['app.py', '--flag', 'value']" + assert Path(said["head"]) == tree + # …and it was governed there: the named effect was asked about, once. + assert stub.decision_count == 1 + + +def test_a_bare_interpreter_name_is_looked_up_the_way_a_shell_would( + application: Path, tmp_path: Path +) -> None: + tree = _tree(tmp_path) + pack = plant_spawn_pack(tmp_path) + environment = plain_environment() + environment["PATH"] = f"{application / 'bin'}{os.pathsep}{environment.get('PATH', '')}" + stub = Stub("allow") + with serve(stub, tmp_path / "d.sock") as socket_path: + finished = instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--", "python", "app.py", cwd=tree, env=environment, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert "found application" in finished.stdout + assert stub.decision_count == 1 + + +def test_without_the_word_the_program_runs_in_this_commands_own_interpreter( + application: Path, tmp_path: Path +) -> None: + """The difference the word makes, so the case above is not vacuous.""" + tree = _tree(tmp_path) + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "run", "--pack", str(pack), "--socket", str(tmp_path / "absent.sock"), "--scope", "local", + "--", "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == 1 + assert "No module named 'only_in_the_application'" in finished.stderr + + +def test_a_denial_under_a_named_interpreter_stops_the_effect_there_too( + application: Path, tmp_path: Path +) -> None: + tree = tmp_path / "tree" + tree.mkdir() + marker = tree / "ran" + (tree / "app.py").write_text( + f'import subprocess\n\nsubprocess.run(["touch", {str(marker)!r}], check=True)\n', + encoding="utf-8", + ) + pack = plant_spawn_pack(tmp_path) + stub = Stub("deny") + with serve(stub, tmp_path / "d.sock") as socket_path: + finished = instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--", str(application / "bin" / "python"), "app.py", cwd=tree, + ) # fmt: skip + assert stub.decision_count == 1 + assert not marker.exists() + assert finished.returncode == 1 + assert "Denied" in finished.stderr + + +def test_the_programs_own_exit_code_comes_back_from_the_named_interpreter( + application: Path, tmp_path: Path +) -> None: + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text("import sys\n\nsys.exit(3)\n", encoding="utf-8") + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "run", "--pack", str(pack), "--socket", str(tmp_path / "absent.sock"), "--scope", "local", + "--", str(application / "bin" / "python"), "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == 3, finished.stderr + + +def test_a_verification_runs_under_the_named_interpreter_as_well( + application: Path, tmp_path: Path +) -> None: + """The harness is a process of its own, so it has to be lent the same three.""" + tree = _tree(tmp_path) + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "verify", "--pack", str(pack), "--socket", str(tmp_path / "absent.sock"), + "--scope", "local", "--", str(application / "bin" / "python"), "app.py", cwd=tree, + ) # fmt: skip + # Nobody is listening, so nothing is concluded — but it is the HARNESS that + # says so, from inside the named interpreter, which is the point: before + # the three were lent to it, it ended on an import error and said nothing. + assert finished.returncode == exit_codes.EXIT_COULD_NOT_ASK, finished.stderr + assert "the chain could not be read" in finished.stderr + assert "No module named" not in finished.stderr + + +def test_a_name_that_finds_no_interpreter_is_a_misuse(tmp_path: Path) -> None: + out, err = io.StringIO(), io.StringIO() + pack = plant_spawn_pack(tmp_path) + code = commands.main( + ["run", "--pack", str(pack), "--socket", str(tmp_path / "absent.sock"), "--scope", + "local", "--", str(tmp_path / "bin" / "python3"), "app.py"], + out=out, err=err, + ) # fmt: skip + assert code == exit_codes.EXIT_MISUSE + assert "python3" in err.getvalue() and "interpreter" in err.getvalue() + + +def test_an_interpreter_too_old_to_hold_the_boundary_is_refused_before_it_is_handed_anything( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Refused by THIS process, in words, rather than left to fail on syntax there.""" + monkeypatch.setattr(interpreter, "MINIMUM", (sys.version_info[0], sys.version_info[1] + 1)) + handed: list[object] = [] + with pytest.raises(interpreter.launch.LaunchMisuse, match="or later"): + interpreter.hand_the_command_to( + sys.executable, ["instrument", "run"], execv=lambda *a: handed.append(a) + ) + assert handed == [] + + +def test_this_commands_own_interpreter_is_not_handed_anything(tmp_path: Path) -> None: + """Naming the interpreter already running is the in-process path, word removed.""" + assert interpreter.is_this_one(sys.executable) + assert not interpreter.is_this_one("/somewhere/else/bin/python") + + +def test_what_is_lent_is_three_packages_and_their_metadata_and_nothing_else() -> None: + lent = interpreter.lent_locations() + assert set(lent) == {"packages", "distributions"} + assert set(lent["packages"]) == {"sayfirst_cli", "sayfirst_boundary", "sayfirst_contract"} + assert set(lent["distributions"]) == {"sayfirst-cli", "sayfirst-boundary", "sayfirst-contract"} + for name, where in lent["packages"].items(): + assert (Path(where) / name / "__init__.py").is_file(), (name, where) + for name, where in lent["distributions"].items(): + assert list(Path(where).glob(f"{name.replace('-', '_')}-*.dist-info")), (name, where) + + +def test_the_floor_is_the_one_this_distribution_declares() -> None: + """Two copies of one number, held together: a process cannot read `pyproject.toml` + from an installed wheel, and the project file cannot import a module.""" + import re + import tomllib + + declared = tomllib.loads( + (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(encoding="utf-8") + )["project"]["requires-python"] + floor = re.search(r">=\s*(\d+)\.(\d+)", declared) + assert floor is not None, declared + assert (int(floor[1]), int(floor[2])) == interpreter.MINIMUM + + +# -- C4: console-script agents (a shebang) and refused runners -------------------- + + +def test_a_runner_in_the_first_position_is_refused_with_the_way_that_works( + tmp_path: Path, +) -> None: + for runner in ("uv", "poetry", "pdm", "pipenv"): + with pytest.raises(interpreter.launch.LaunchMisuse) as refusal: + interpreter.selection([runner, "run", "app.py"]) + said = str(refusal.value) + assert runner in said and "which python" in said + # `uv` not followed by `run` is not a runner invocation and is left alone + # (it would be an executable named `uv`, which is not a shebang Python). + assert interpreter.selection(["uv", "--version"]) is None + + +def test_an_executable_script_hands_over_by_its_shebang(application: Path, tmp_path: Path) -> None: + agent = tmp_path / "myagent" + agent.write_text(f"#!{application / 'bin' / 'python'}\n{APP}", encoding="utf-8") + agent.chmod(0o755) + chosen = interpreter.selection([str(agent), "--flag"]) + assert chosen is not None + executable, program = chosen + # The shebang path is kept as written (the venv's python), not realpath- + # resolved, because that interpreter is what sets up the venv. + assert Path(executable) == application / "bin" / "python" + # The script itself is the program that interpreter runs, argv[0] included. + assert program == [str(agent), "--flag"] + + +def test_a_shebang_using_env_is_read(application: Path, tmp_path: Path) -> None: + agent = tmp_path / "myagent" + agent.write_text("#!/usr/bin/env python3\nprint('x')\n", encoding="utf-8") + agent.chmod(0o755) + chosen = interpreter.selection([str(agent)]) + assert chosen is not None + # `env python3` is resolved on PATH the way a shell would, and handed over + # as that path (which need not be this test's own interpreter). + import shutil + + assert chosen[0] == os.path.abspath(shutil.which("python3")) + assert chosen[1] == [str(agent)] + + +def test_a_non_executable_script_is_left_in_process_whatever_its_shebang( + application: Path, tmp_path: Path +) -> None: + agent = tmp_path / "app.py" + agent.write_text(f"#!{application / 'bin' / 'python'}\nprint('x')\n", encoding="utf-8") + # Not executable: a shebang only matters for a file the kernel would run. + assert interpreter.selection([str(agent)]) is None + + +def test_a_shebang_that_is_not_a_python_is_left_alone(tmp_path: Path) -> None: + for line in ("#!/bin/sh\n", "#!/usr/bin/env -S uv run python\n", "#!/usr/bin/env bash\n"): + script = tmp_path / "s" + script.write_text(line + "true\n", encoding="utf-8") + script.chmod(0o755) + assert interpreter.shebang_interpreter(str(script)) is None + assert interpreter.selection([str(script)]) is None + + +def test_the_console_script_agent_runs_under_its_own_interpreter( + application: Path, tmp_path: Path +) -> None: + """The whole point: an agent started as an executable, not as `python …`, is + governed inside the interpreter its shebang names and keeps its dependencies.""" + tree = tmp_path / "tree" + tree.mkdir() + agent = tree / "myagent" + agent.write_text(f"#!{application / 'bin' / 'python'}\n{APP}", encoding="utf-8") + agent.chmod(0o755) + pack = plant_spawn_pack(tmp_path) + stub = Stub("allow") + with serve(stub, tmp_path / "d.sock") as socket_path: + finished = instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--", "./myagent", cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert "found application" in finished.stdout + assert stub.decision_count == 1 + + +def test_a_refused_runner_governs_nothing_and_says_the_fix(tmp_path: Path) -> None: + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text('print("ran")\n', encoding="utf-8") + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "run", "--pack", str(pack), "--socket", str(tmp_path / "absent.sock"), "--scope", "local", + "--", "uv", "run", "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == exit_codes.EXIT_MISUSE + assert "cannot govern a program started by 'uv'" in finished.stderr + assert "ran" not in finished.stdout + + +# -- review findings: re-exec must not re-select; the probe ignores startup ------ + + +def test_a_handed_over_command_does_not_choose_the_interpreter_again( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The mark a hand-off sets makes the second `sayfirst instrument` run its + target in place, without reading its first word again.""" + monkeypatch.setenv(interpreter.CHOSEN_VARIABLE, "1") + # An arbitrary namespace with a target that WOULD otherwise be selected + # (a `python` word): with the mark set, selection is skipped and the target + # is left for the in-process run, and the mark is consumed. + parser = commands.build_parser() + ns = parser.parse_args(["run", "--pack", "subprocess", "--scope", "local", "--", "python", "x"]) + commands._to_the_interpreter_the_target_names(parser, ns) + assert ns.target == ["python", "x"] # untouched: no re-selection + assert interpreter.CHOSEN_VARIABLE not in os.environ # consumed + + +def test_an_executable_whose_shebang_names_a_third_interpreter_is_not_re_followed( + application: Path, tmp_path: Path +) -> None: + """Named interpreter A, program's shebang B: the program runs under A, the one + named, not B. Before the fix the handed-over process read the shebang again and + exec'd into B, running the program under the wrong environment.""" + tree = tmp_path / "tree" + tree.mkdir() + # The program is executable and its shebang names THIS test's interpreter, + # which does not have `only_in_the_application`; the named interpreter + # (`application`) does. If the program ran under the shebang's interpreter it + # would fail to import it. + agent = tree / "agent" + agent.write_text(f"#!{sys.executable}\n{APP}", encoding="utf-8") + agent.chmod(0o755) + pack = plant_spawn_pack(tmp_path) + stub = Stub("allow") + with serve(stub, tmp_path / "d.sock") as socket_path: + finished = instrument( + "run", "--pack", str(pack), "--socket", str(socket_path), "--scope", "local", + "--", str(application / "bin" / "python"), "agent", cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + said = dict(line.split(" ", 1) for line in finished.stdout.splitlines()) + assert said["found"] == "application", finished.stdout + assert Path(said["prefix"]) == application + + +def test_the_version_probe_ignores_a_targets_startup_output(tmp_path: Path) -> None: + """A target whose sitecustomize announces itself on startup is a usable + interpreter, not a malformed version: the probe runs isolated and reads its + own marked line.""" + import sysconfig + import venv + + root = tmp_path / "noisy" + venv.create(root, with_pip=False, symlinks=True) + purelib = Path(sysconfig.get_path("purelib", vars={"base": str(root), "platbase": str(root)})) + (purelib / "sitecustomize.py").write_text("print('environment ready')\n", "utf-8") + # -I in the probe suppresses site/sitecustomize, so the answer is clean. + assert interpreter.version_of(str(root / "bin" / "python")) == sys.version_info[:2] + + +# -- review findings: a missing shebang interpreter, the ceiling, followed children, safe path -- + + +def test_a_shebang_naming_a_python_that_is_not_there_is_a_misuse(tmp_path: Path) -> None: + """The kernel refuses such a script (status 127); running it under this command's + own interpreter instead is the dishonest reading the module refuses by name.""" + for line in ("#!/usr/bin/env python3.99\n", f"#!{tmp_path / 'gone' / 'python3'}\n"): + script = tmp_path / "agent" + script.write_text(line + "print('ran')\n", encoding="utf-8") + script.chmod(0o755) + with pytest.raises(interpreter.launch.LaunchMisuse, match="no such interpreter"): + interpreter.selection([str(script)]) + + +def test_the_command_refuses_a_script_whose_python_is_missing(tmp_path: Path) -> None: + marker = tmp_path / "ran" + script = tmp_path / "agent" + script.write_text( + f"#!/usr/bin/env python3.99\nopen({str(marker)!r}, 'w').close()\n", encoding="utf-8" + ) + script.chmod(0o755) + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "run", "--pack", str(pack), "--socket", str(tmp_path / "absent.sock"), "--scope", + "local", "--", str(script), cwd=tmp_path, + ) # fmt: skip + assert finished.returncode == exit_codes.EXIT_MISUSE, finished.stderr + assert "python3.99" in finished.stderr + assert "Traceback" not in finished.stderr + assert not marker.exists() + + +def test_the_ceiling_is_the_one_this_distribution_declares() -> None: + """The lent packages declare `<3.15`; the copy a running process reads is held to it.""" + import re + import tomllib + + declared = tomllib.loads( + (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(encoding="utf-8") + )["project"]["requires-python"] + ceiling = re.search(r"<\s*(\d+)\.(\d+)", declared) + assert ceiling is not None, declared + assert (int(ceiling[1]), int(ceiling[2])) == interpreter.BEYOND + + +def test_an_interpreter_too_new_for_the_lent_packages_is_refused_before_it_is_handed_anything( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(interpreter, "BEYOND", (sys.version_info[0], sys.version_info[1])) + handed: list[object] = [] + with pytest.raises(interpreter.launch.LaunchMisuse, match="before"): + interpreter.hand_the_command_to( + sys.executable, ["instrument", "run"], execv=lambda *a: handed.append(a) + ) + assert handed == [] + + +def test_following_children_under_an_interpreter_without_this_client_is_refused( + application: Path, tmp_path: Path +) -> None: + """The packages are lent to ONE process; a child is a fresh one with nothing lent, + so every Python child would die at start-up. Refused before anything runs.""" + tree = _tree(tmp_path) + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "run", "--pack", str(pack), "--socket", str(tmp_path / "absent.sock"), "--scope", + "local", "--follow-children", "--", str(application / "bin" / "python"), "app.py", + cwd=tree, + ) # fmt: skip + assert finished.returncode == exit_codes.EXIT_MISUSE, (finished.stdout, finished.stderr) + assert "--follow-children" in finished.stderr + assert "found application" not in finished.stdout + + +@pytest.fixture(scope="module") +def application_with_this_client(tmp_path_factory: pytest.TempPathFactory) -> Path: + """A second environment that can import this client on its own, as an install would.""" + root = tmp_path_factory.mktemp("installed") / "env" + venv.create(root, with_pip=False, symlinks=True) + purelib = Path(sysconfig.get_path("purelib", vars={"base": str(root), "platbase": str(root)})) + (purelib / "only_in_the_application.py").write_text('WHERE = "application"\n', "utf-8") + locations = sorted(set(interpreter.lent_locations()["packages"].values())) + (purelib / "this_client.pth").write_text("\n".join(locations) + "\n", "utf-8") + return root + + +def test_following_children_under_an_interpreter_with_this_client_is_handed_over( + application_with_this_client: Path, tmp_path: Path +) -> None: + tree = _tree(tmp_path) + pack = plant_spawn_pack(tmp_path) + stub = Stub("allow") + with serve(stub, tmp_path / "d.sock"): + finished = instrument( + "run", "--pack", str(pack), "--socket", str(tmp_path / "d.sock"), "--scope", + "local", "--follow-children", "--", + str(application_with_this_client / "bin" / "python"), "app.py", + cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, (finished.stdout, finished.stderr) + assert "found application" in finished.stdout + + +SAFE_PATH_APP = """\ +import sys + +import mylib + +print("head", sys.path[0]) +print("found", mylib.WHERE) +""" + + +def _a_safe_path_tree(tmp_path: Path) -> tuple[Path, Path]: + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text(SAFE_PATH_APP, encoding="utf-8") + libs = tmp_path / "libs" + libs.mkdir() + (libs / "mylib.py").write_text('WHERE = "libs"\n', encoding="utf-8") + return tree, libs + + +def test_a_safe_path_keeps_the_import_path_the_interpreter_gave_the_program( + tmp_path: Path, +) -> None: + """`PYTHONSAFEPATH` keeps the script's directory off the path, so the head is the + person's own first entry; the launcher replaced it and the program's import failed.""" + import subprocess + + tree, libs = _a_safe_path_tree(tmp_path) + environment = {**os.environ, "PYTHONSAFEPATH": "1", "PYTHONPATH": str(libs)} + plain = subprocess.run( + [sys.executable, "app.py"], cwd=tree, env=environment, capture_output=True, text=True, + check=False, + ) # fmt: skip + assert plain.returncode == 0, plain.stderr + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "run", "--pack", str(pack), "--socket", str(tmp_path / "absent.sock"), "--scope", + "local", "--", "app.py", cwd=tree, env=environment, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert finished.stdout == plain.stdout + + +def test_a_safe_path_is_kept_under_a_named_interpreter_too( + application: Path, tmp_path: Path +) -> None: + """The hand-over starts that interpreter with `-P` of its own; the person's setting + is read off the environment, and the program gets the path it would have had.""" + import subprocess + + tree, libs = _a_safe_path_tree(tmp_path) + environment = {**os.environ, "PYTHONSAFEPATH": "1", "PYTHONPATH": str(libs)} + plain = subprocess.run( + [str(application / "bin" / "python"), "app.py"], cwd=tree, env=environment, + capture_output=True, text=True, check=False, + ) # fmt: skip + assert plain.returncode == 0, plain.stderr + pack = plant_spawn_pack(tmp_path) + finished = instrument( + "run", "--pack", str(pack), "--socket", str(tmp_path / "absent.sock"), "--scope", + "local", "--", str(application / "bin" / "python"), "app.py", cwd=tree, + env=environment, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert finished.stdout == plain.stdout diff --git a/tests/test_pack_designation.py b/tests/test_pack_designation.py new file mode 100644 index 0000000..9f8f20a --- /dev/null +++ b/tests/test_pack_designation.py @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: Apache-2.0 +"""How `--pack` is read: a path is a directory, and a bare word is a shipped pack's name. + +Article 9 asks for a pack to be « explicitly designated by the user » and +forbids a registry. A person who types `--pack` and the name of a pack this +distribution ships has designated it, explicitly; what would be a registry is +anything that made the answer depend on something other than what they typed +and what this distribution carries — a search path, a directory consulted on +the way, a name somebody else can publish under. So the rule is about the +SPELLING and nothing else: + +* a designation with a path separator in it (or `.`, or `..`) is a directory, + read exactly as it always was; +* anything else is the name of a pack inside this installed distribution, and a + name it does not ship is refused — the working directory is never consulted + for it, so a directory somebody left there cannot become the code that runs. + +The cases below hold both halves, and the two ways they could leak into each +other. +""" + +from __future__ import annotations + +import io +from pathlib import Path + +import pytest +from governed_programs import SPAWNING_APP, instrument, plant_spawn_pack +from sayfirst_contract_stub.stub import Stub +from sayfirst_contract_stub.stub_http import serve + +from sayfirst_cli import exit_codes, packs_cmd +from sayfirst_cli.instrument import designation, manifest + +SHIPPED = {path.name: path for path in packs_cmd.shipped_packs()} + + +def test_this_distribution_ships_the_three_packs_these_cases_name() -> None: + assert set(SHIPPED) == {"database", "http-client", "subprocess"} + + +@pytest.mark.parametrize("name", sorted(SHIPPED)) +def test_a_shipped_pack_is_designated_by_its_name(name: str) -> None: + resolved = designation.directory_of(name) + assert resolved == SHIPPED[name] + # The name a person types is the name the pack gives itself, so that one + # pack has one spelling: a directory name that drifted from its manifest + # would make `packs list` print one word and `--pack` take another. + assert manifest.read_pack(resolved).name == name + + +def test_a_designation_with_a_separator_is_that_directory_and_nothing_else( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + own = plant_spawn_pack(tmp_path, name="subprocess") + monkeypatch.chdir(tmp_path) + assert designation.directory_of("./subprocess") == Path("./subprocess") + assert designation.directory_of(str(own)) == own + assert manifest.read_pack(designation.directory_of("./subprocess")).name == "process-effects" + + +def test_a_bare_name_never_reads_the_working_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A directory of the same name beside the program is not the pack that runs.""" + plant_spawn_pack(tmp_path, name="subprocess") + monkeypatch.chdir(tmp_path) + assert designation.directory_of("subprocess") == SHIPPED["subprocess"] + + +def test_a_bare_word_that_names_no_shipped_pack_is_refused_with_the_way_to_say_a_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + plant_spawn_pack(tmp_path, name="mine") + monkeypatch.chdir(tmp_path) + with pytest.raises(manifest.PackInvalid) as refusal: + designation.directory_of("mine") + said = str(refusal.value) + assert "database, http-client, subprocess" in said + assert "./mine" in said + + +@pytest.mark.parametrize("spelling", [".", "..", "../packs/own", "/srv/packs/own", "own/"]) +def test_these_spellings_are_paths(spelling: str) -> None: + assert designation.directory_of(spelling) == Path(spelling) + + +@pytest.mark.parametrize( + "spelling", ["", "Subprocess", "sub process", "~subprocess", "sub_process"] +) +def test_a_bare_word_that_could_not_be_a_pack_name_is_refused(spelling: str) -> None: + with pytest.raises(manifest.PackInvalid): + designation.directory_of(spelling) + + +def test_a_governed_run_takes_a_shipped_pack_by_name(tmp_path: Path) -> None: + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text(SPAWNING_APP, encoding="utf-8") + stub = Stub("allow") + with serve(stub, tmp_path / "d.sock") as socket_path: + finished = instrument( + "run", "--pack", "subprocess", "--socket", str(socket_path), "--scope", "local", + "--", "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert stub.decision_count == 1 + + +def test_a_governed_run_still_takes_a_directory_of_ones_own(tmp_path: Path) -> None: + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text(SPAWNING_APP, encoding="utf-8") + plant_spawn_pack(tree, name="own-pack") + stub = Stub("allow") + with serve(stub, tmp_path / "d.sock") as socket_path: + finished = instrument( + "run", "--pack", "./own-pack", "--socket", str(socket_path), "--scope", "local", + "--", "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == 0, finished.stderr + assert stub.decision_count == 1 + + +def test_a_name_nothing_ships_is_a_misuse_before_anything_runs(tmp_path: Path) -> None: + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text('print("ran")\n', encoding="utf-8") + for verb in ("run", "verify"): + finished = instrument( + verb, "--pack", "no-such-pack", "--socket", str(tmp_path / "absent.sock"), + "--scope", "local", "--", "app.py", cwd=tree, + ) # fmt: skip + assert finished.returncode == exit_codes.EXIT_MISUSE, (verb, finished.stderr) + assert finished.stderr.startswith("no-such-pack: "), finished.stderr + assert "ran" not in finished.stdout + + +def test_packs_check_reads_a_designation_the_way_the_engine_will() -> None: + out, err = io.StringIO(), io.StringIO() + assert packs_cmd.main(["check", "subprocess"], out=out, err=err) == 0, err.getvalue() + assert out.getvalue() == "ok subprocess\n" + out, err = io.StringIO(), io.StringIO() + assert packs_cmd.main(["check", "no-such-pack"], out=out, err=err) == exit_codes.EXIT_MISUSE diff --git a/tests/test_packs_cmd.py b/tests/test_packs_cmd.py index dc23718..f00d450 100644 --- a/tests/test_packs_cmd.py +++ b/tests/test_packs_cmd.py @@ -15,7 +15,7 @@ import pytest from sayfirst_cli import exit_codes, main, packs_cmd -from sayfirst_cli.instrument import manifest +from sayfirst_cli.instrument import designation, manifest REPOSITORY = Path(__file__).resolve().parents[1] SUBPROCESS_PACK = REPOSITORY / "src" / "sayfirst_cli" / "packs" / "subprocess" @@ -67,7 +67,9 @@ def packs_root(monkeypatch: pytest.MonkeyPatch, root: Path) -> None: the distribution itself, and every guard that walks the shipped packs would then read it as shipped. """ - monkeypatch.setattr(packs_cmd.importlib.resources, "files", lambda package: root) + # The walk lives beside the rule that reads a designation, since both read the + # one shipped set; `packs_cmd.shipped_packs` is that function under its old name. + monkeypatch.setattr(designation.importlib.resources, "files", lambda package: root) def test_main_dispatches_packs_to_the_packs_command() -> None: @@ -152,6 +154,29 @@ def test_check_names_the_member_and_the_rule_for_an_invalid_pack(tmp_path: Path) assert str(empty) in err +@pytest.mark.parametrize( + "declared", + [ + 'uninterposed_events = "example_module.other"\n', + 'uninterposed_events = ["example_module.effect"]\n', + 'uninterposed_events = ["example_module.other"]\ninner_events = ["elsewhere.event"]\n', + ], +) +def test_check_refuses_what_the_verifier_would_refuse(tmp_path: Path, declared: str) -> None: + """`check` answers for the pack a later `verify` reads, not only for the engine's part. + + The verifier also reads what the pack says it does NOT interpose, and which + of those its own call raises; a declaration it cannot act on is refused + there with 64. `check` said « ok » for the same pack, so the one command + that exists to say whether a pack will work said it would. + """ + pack = plant(tmp_path, "declaring", GOOD_MANIFEST + declared) + code, out, err = run("packs", "check", str(pack)) + assert code == exit_codes.EXIT_MISUSE, (out, err) + assert out == "" + assert "[[point]] 1" in err + + def test_check_of_a_directory_that_does_not_exist_is_the_same_misuse(tmp_path: Path) -> None: absent = tmp_path / "nowhere" code, out, err = run("packs", "check", str(absent)) diff --git a/tests/test_packs_shipped.py b/tests/test_packs_shipped.py index 71eb43d..b024040 100644 --- a/tests/test_packs_shipped.py +++ b/tests/test_packs_shipped.py @@ -210,6 +210,7 @@ def test_a_table_row_the_pattern_does_not_recognise_fails_the_guard( #: are held now. VERIFY_CODES: tuple[str, ...] = ( "allow", + "refused", "could_not_ask", "check_failed", "could_not_check", diff --git a/tests/test_reads.py b/tests/test_reads.py index 8aba6c4..22b80ad 100644 --- a/tests/test_reads.py +++ b/tests/test_reads.py @@ -9,6 +9,7 @@ import io import json +import os from types import SimpleNamespace import pytest @@ -29,9 +30,9 @@ def replay(monkeypatch): def arrange(*responses): http = Replies(responses) - def connect(profile): + def connect(profile, **_): assert profile.scope == "team-ops" - assert profile.socket_path == "daemon.sock" + assert profile.socket_path == os.path.abspath("daemon.sock") return verified(profile, http) monkeypatch.setattr(reads, "connect", connect) @@ -203,7 +204,7 @@ def test_open_failure_is_a_could_not_ask_whatever_the_code( expected = 4 from sayfirst_cli import reads - def fail(profile): + def fail(profile, **_): raise SocketClientProblem( Problem(problem_code, "no connection", False, CONTRACT_GENERATION) ) diff --git a/tests/test_review_bounds.py b/tests/test_review_bounds.py new file mode 100644 index 0000000..c6002e4 --- /dev/null +++ b/tests/test_review_bounds.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Three bounds a client of a control plane must hold whatever the far end does. + +- **A uid with no account.** A container started under an arbitrary uid has no + account name to spell. The daemon names such a peer by its number, and the + launcher's spelling must be the one it compares against — the launcher ended + in a traceback and status 1 instead, this client's « deny ». +- **A far end that never answers.** `ask` and every read opened a connection + with no bound, so a process that accepted it and then said nothing held the + command for ever. They now wait a bounded time and report that the control + plane could not be asked. +- **An answer nested past what this client reads.** A daemon may add members to + a document the contract publishes, and `ask` rendered a decision with no + depth bound: an `allow` nested thousands deep ended, on an interpreter whose + encoder recurses, as a traceback and status 1. The reads already refuse such + a document as one this client could not read; `ask` now applies the same rule. +""" + +from __future__ import annotations + +import io +import os +import socket +import time +from pathlib import Path + +import pytest +from contract_absence import contract_is_installed, skip_without_the_contract + + +def test_a_uid_with_no_account_is_spelled_by_its_number(monkeypatch: pytest.MonkeyPatch) -> None: + from sayfirst_cli.instrument import launch + + def no_account(uid: int) -> object: + raise KeyError(f"getpwuid(): uid not found: {uid}") + + monkeypatch.setattr(launch.pwd, "getpwuid", no_account) + assert launch.this_account() == f"user:{os.geteuid()}" + + +def test_ask_gives_up_on_a_far_end_that_never_answers(request, tmp_path: Path) -> None: + if not contract_is_installed(): + skip_without_the_contract(request, "ask builds the contract's own client") + from sayfirst_cli import ask, reads + + address = tmp_path / "daemon.sock" + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + server.bind(str(address)) + server.listen(4) + out, err = io.StringIO(), io.StringIO() + started = time.monotonic() + code = ask.main( + ["--capability", "process.spawn", "--scope", "local", "--socket", str(address)], + out=out, + err=err, + ) + elapsed = time.monotonic() - started + assert code == 4, (out.getvalue(), err.getvalue()) + assert elapsed < reads.READ_TIMEOUT + 5.0, f"ask waited {elapsed:.1f}s" + + +def test_a_decision_nested_past_the_bound_is_an_answer_ask_could_not_read( + request, monkeypatch: pytest.MonkeyPatch +) -> None: + if not contract_is_installed(): + skip_without_the_contract(request, "ask renders the contract's own decision") + from sayfirst_contract.client import Answered + from sayfirst_contract.decisions import Decision, Outcome, Reason + + from sayfirst_cli import ask + + deep: object = "bottom" + for _ in range(1500): + deep = {"x": deep} + decision = Decision( + decision_ref="dec-1", + scope="local", + capability="process.spawn", + outcome=Outcome.ALLOW, + reason=Reason.POLICY_ALLOWS, + policy_version="sha256:" + "a" * 64, + approval_ref=None, + decided_at="2026-09-23T12:00:00+00:00", + correlation=None, + contract_generation=1, + extra={"deep": deep}, + ) + + class Credential: + uid = os.geteuid() + pid = 1 + + class Connection: + server_credential = Credential() + expected_uid = os.geteuid() + verified = True + + def ask_decision(self, question: object, declared: object) -> object: + return Answered(decision, 1) + + def close(self) -> None: ... + + monkeypatch.setattr(ask, "connect", lambda profile, **_: Connection()) + out, err = io.StringIO(), io.StringIO() + code = ask.main( + ["--capability", "process.spawn", "--scope", "local", "--socket", "d.sock", "--json"], + out=out, + err=err, + ) + assert code == 4, (out.getvalue(), err.getvalue()) + assert "Traceback" not in err.getvalue() diff --git a/tests/test_verification_claims_what_it_established.py b/tests/test_verification_claims_what_it_established.py new file mode 100644 index 0000000..0113c72 --- /dev/null +++ b/tests/test_verification_claims_what_it_established.py @@ -0,0 +1,541 @@ +# SPDX-License-Identifier: Apache-2.0 +"""One rule, seven ways it was broken: what a verification may assert. + +**The rule.** `sayfirst instrument verify` may report `governed` for a point +only from evidence it actually READ, actually found SOUND, and actually tied to +the effect it watched — and only over a run it watched WHOLE. Anything it could +not establish is carried as an incompleteness, which is never a pass and never a +finding: `ungoverned` is a finding this client made and needs the chain read to +its end, `governed` is a pass and needs a record of THIS effect on evidence the +chain's own verification reports intact. + +Every test here was written before the source that satisfies it, and every one +of them observed the defect it names first. They are grouped by the way the +rule was broken rather than by the function that broke it, because the seven +were one question and not seven call sites. + +The end-to-end cases go through the shipped console script against a canned +daemon, for the reason `test_instrument_verify.py` gives: the verifier's whole +mechanism is a subprocess under an audit hook that can never be removed. The +component cases drive one consultation against a connection double, because +what they assert is an ORDER of reads and answers that no canned daemon can be +made to produce on demand. +""" + +from __future__ import annotations + +import os +import subprocess +from collections.abc import Mapping +from pathlib import Path +from types import SimpleNamespace + +import pytest +from canned_daemon import answering_by_path +from documents import ( + ANOTHER_ACCOUNT, + another_executions_effect_page, + no_evidence_page, + recorded_effect_page, +) +from governed_programs import CONSOLE_SCRIPT, instrument, plant_spawn_pack +from sayfirst_contract.client import Answered +from sayfirst_contract.generation import CONTRACT_GENERATION + +from sayfirst_cli import exit_codes, reads +from sayfirst_cli.instrument import harness, manifest + +#: The capability the planted spawn pack declares. +SPAWN = "process.spawn" + +#: The point that pack watches, as the report spells it. +POINT = f"process-effects subprocess.Popen {SPAWN}" + + +def a_tree(root: Path, body: str, *, name: str = "tree") -> Path: + """A one-file program, in a directory of its own.""" + tree = root / name + tree.mkdir() + (tree / "app.py").write_text(body, encoding="utf-8") + return tree + + +def a_marking_tree(root: Path, marker: Path, *, name: str = "tree") -> Path: + """A program whose spawned process leaves a file behind, or does not. + + The file is how « the effect was aborted » is told from « the verdict was + written after the effect happened »: every assertion about a verdict is + equally true of a hook that raised too late. + """ + return a_tree( + root, + f"import subprocess\n\nsubprocess.run(['touch', {str(marker)!r}], check=True)\n", + name=name, + ) + + +def verify(*argv: str, cwd: Path, timeout: float = 120) -> tuple[int, str, str]: + """Run the real command the way a person runs it.""" + finished = instrument("verify", *argv, cwd=cwd) + return finished.returncode, finished.stdout, finished.stderr + + +def verified(pack: Path, address: Path, tree: Path, *extra: str) -> tuple[int, str, str]: + """`verify --ungoverned` over one pack and one program: the shape every case shares.""" + return verify( + "--pack", + str(pack), + "--socket", + str(address), + "--scope", + "local", + "--ungoverned", + *extra, + "--", + "app.py", + cwd=tree, + ) + + +# --- 1. a record supports an effect only if it is THIS execution's ------------ + + +def test_a_record_of_another_execution_does_not_make_this_effect_governed( + tmp_path: Path, +) -> None: + """P1-1. The chain holds one allow for the capability, recorded for somebody else. + + Nothing about that record says it preceded THIS effect: another principal + asked it, on another connection, about another call. A verifier that counts + it has proven that a decision exists somewhere in the scope, which is not + the claim `docs/PACKS.md` makes and not a claim anybody wants. + + The chain WAS read to its end, so what this run established is an absence + of any record of its own: that is a finding — exit 6 — and the effect is + aborted, which the missing marker is the proof of. + """ + pack = plant_spawn_pack(tmp_path) + marker = tmp_path / "the-effect-happened" + routes = {"/scopes/": (200, [no_evidence_page(), another_executions_effect_page(SPAWN)])} + with answering_by_path(tmp_path / "d.sock", routes) as address: + code, stdout, stderr = verified(pack, address, a_marking_tree(tmp_path, marker)) + assert code != 0, (stdout, stderr) + assert code == exit_codes.EXIT_CHECK_FAILED, (stdout, stderr) + assert f"ungoverned {POINT} events=0" in stdout + assert f"\n{harness.GOVERNED} {POINT}" not in f"\n{stdout}" + assert not marker.exists(), "the effect happened although no decision of this run covered it" + + +def test_the_same_page_recorded_for_this_execution_is_governed(tmp_path: Path) -> None: + """The anti-vacuity of the test above: the fixture pair differs in identity alone. + + Without this, « nothing is ever governed again » would satisfy the rule + just as well, and a verifier that cannot pass is worth no more than one + that cannot fail. + """ + pack = plant_spawn_pack(tmp_path) + marker = tmp_path / "the-effect-happened" + routes = {"/scopes/": (200, [no_evidence_page(), recorded_effect_page(SPAWN)])} + with answering_by_path(tmp_path / "d.sock", routes) as address: + code, stdout, stderr = verified(pack, address, a_marking_tree(tmp_path, marker)) + assert code == 0, (stdout, stderr) + assert f"governed {POINT} events=1" in stdout + assert marker.exists() + + +def test_the_two_fixtures_differ_in_nothing_but_the_identity_of_the_execution() -> None: + """The invariant that replaces « vary what the matcher reads ». + + `tests/documents.py` varied the capability and the outcome, which were + exactly the two members the matcher consumed, so no success fixture in this + repository could see the association defect. What holds the pair apart now + is the identity of the execution the record was made for — and this test + fails if the two fixtures ever agree on it again. + """ + ours = recorded_effect_page(SPAWN)["entries"][0] + theirs = another_executions_effect_page(SPAWN)["entries"][0] + assert ours["body"] == theirs["body"], "the pair must not differ in what the matcher reads" + assert ours["principal"] != theirs["principal"] + assert theirs["principal"] == ANOTHER_ACCOUNT + assert ours["connection_id"] != theirs["connection_id"] + + +# --- 2. a record on evidence the chain itself calls damaged supports nothing -- + + +def test_a_record_on_a_page_reporting_a_broken_chain_is_not_a_pass(tmp_path: Path) -> None: + """P1-4. The page carries an allow and says, of its own range, `broken_at`. + + The served verification is the daemon's own reading of the chain it just + handed over. Throwing it away and counting the record anyway is a claim + made from evidence its own writer declared unusable, which is precisely the + claim article 2 bounds by the evidence actually held. + + It is not `ungoverned` either: a damaged chain is not an established + absence. The run carries the incompleteness, exits 7 — the code for a check + that could not conclude — and the effect is still aborted, because an + effect whose governance could not be established is not one this harness + may let through. + """ + pack = plant_spawn_pack(tmp_path) + marker = tmp_path / "the-effect-happened" + damaged = recorded_effect_page(SPAWN, condition="broken_at") + routes = {"/scopes/": (200, [no_evidence_page(), damaged])} + with answering_by_path(tmp_path / "d.sock", routes) as address: + code, stdout, stderr = verified(pack, address, a_marking_tree(tmp_path, marker)) + assert code != 0, (stdout, stderr) + assert code == exit_codes.EXIT_COULD_NOT_CHECK, (stdout, stderr) + assert f"\n{harness.GOVERNED} {POINT} events=1" not in f"\n{stdout}" + assert f"{harness.UNJUDGED}: 1" in stdout + assert not marker.exists(), "the effect happened on evidence the chain called broken" + + +# --- 3. a claim about the run covers the whole run, children included --------- + +#: A program that meets one governed effect itself and forks a worker that +#: meets another. The worker catches its own abort and ends normally, which is +#: what an ordinary worker does with an exception it was written to survive. +FORKING_APP = """\ +import multiprocessing +import subprocess + + +def worker(): + try: + subprocess.run(["true"], check=True) + except RuntimeError as refused: + print("worker saw:", refused, flush=True) + + +subprocess.run(["true"], check=True) +child = multiprocessing.get_context("fork").Process(target=worker) +child.start() +child.join() +print("worker exit:", child.exitcode, flush=True) +""" + + +def test_a_run_that_forked_does_not_report_a_verdict_over_the_child(tmp_path: Path) -> None: + """P1-2. Fork copies the hook, the cursor and the findings; only the parent's are written. + + The worker meets an effect no record covers, is aborted, catches the + exception and exits 0. The parent joins it and — reading its own copies of + objects the child changed in a memory the parent cannot see — reported + `governed`, exit 0, over a run in which an effect was refused. + + Collecting a forked child's findings is not something this harness can do + from the parent's side; saying so is. A run that forked is a run this + report does not cover whole, and an incompleteness is never a pass. + """ + pack = plant_spawn_pack(tmp_path) + routes = {"/scopes/": (200, [no_evidence_page(), recorded_effect_page(SPAWN)])} + with answering_by_path(tmp_path / "d.sock", routes) as address: + code, stdout, stderr = verified(pack, address, a_tree(tmp_path, FORKING_APP)) + assert "worker saw:" in stderr, (stdout, stderr) + assert code != 0, (stdout, stderr) + assert code == exit_codes.EXIT_COULD_NOT_CHECK, (stdout, stderr) + assert f"{harness.UNJUDGED}: 1" in stdout + + +# --- 4. an effect on a path no point names leaves coverage incomplete --------- + + +def plant_a_pack_naming_its_uninterposed_paths(root: Path, *, name: str = "pack") -> Path: + """The spawn pack, plus the declaration of the paths it does NOT interpose. + + A pack is the one place a library's vocabulary may be written down (article + 4), so it is the pack — never the harness — that says by which other events + an effect of its kind reaches the world. + """ + directory = plant_spawn_pack(root, name=name) + manifest_file = directory / manifest.MANIFEST_FILE + manifest_file.write_text( + manifest_file.read_text(encoding="utf-8") + + f'{harness.UNINTERPOSED} = ["os.posix_spawn", "os.exec"]\n', + encoding="utf-8", + ) + return directory + + +def an_alternate_path_tree(root: Path) -> Path: + """A program that spawns twice: once through the interposed call, once past it.""" + return a_tree( + root, + "import os\n" + "import subprocess\n" + "\n" + "subprocess.run(['true'], check=True)\n" + "pid = os.posix_spawn('/bin/sh', ['/bin/sh', '-c', 'exit 0'], os.environ)\n" + "print('second process status:', os.waitpid(pid, 0)[1], flush=True)\n", + ) + + +def test_an_effect_that_reached_the_world_past_every_point_is_not_a_pass( + tmp_path: Path, +) -> None: + """P1-3. Two processes ran, one decision covered one of them, and the run said 0. + + The second creation raises an event no point names, so it was dropped and + the point's verdict — earned by the first — was published as if it were the + whole of the run. `docs/PACKS.md` claims « every effect of a KIND a + designated pack names », and one call shape of that kind reaching the world + unobserved makes the coverage of that claim incomplete. + + **And the second process still runs.** The limit that instrumentation can + miss a call is documented and stays true; what changes is that the report + stops pretending it did not happen. A verifier that aborted the call would + be the confinement mechanism article 2 says this software is not. + """ + pack = plant_a_pack_naming_its_uninterposed_paths(tmp_path) + routes = {"/scopes/": (200, [no_evidence_page(), recorded_effect_page(SPAWN)])} + with answering_by_path(tmp_path / "d.sock", routes) as address: + code, stdout, stderr = verified(pack, address, an_alternate_path_tree(tmp_path)) + assert "second process status: 0" in stderr, (stdout, stderr) + assert code != 0, (stdout, stderr) + assert code == exit_codes.EXIT_COULD_NOT_CHECK, (stdout, stderr) + assert f"{harness.UNJUDGED}: 1" in stdout + + +def test_the_shipped_subprocess_pack_names_the_paths_it_does_not_interpose() -> None: + """The declaration is the shipped pack's, not a fixture's. + + `src/sayfirst_cli/packs/subprocess` interposes one attribute, and the + interpreter offers several other ways to create a process. Naming them is + what lets the verifier count an effect it could not judge instead of + dropping it. + """ + shipped = Path(__file__).resolve().parents[1] / "src/sayfirst_cli/packs/subprocess" + declared = harness.uninterposed_events(manifest.read_pack(shipped)) + named = {event for events in declared.values() for event in events} + assert "os.posix_spawn" in named, sorted(named) + assert len(named) >= 3, sorted(named) + assert "subprocess.Popen" not in named, "a point's own event is interposed, not missed" + + +# --- 5. a record consumed for one effect must not bury another ---------------- + +#: A second capability, so that two consultations of one chain ask different +#: questions of it — which is what two threads of one program do. +READING = "file.read" + + +def an_effect_entry(capability: str, sequence: int) -> Mapping[str, object]: + """One recorded allow of this execution, at a chosen position in the chain.""" + return recorded_effect_page(capability, sequence=sequence)["entries"][0] + + +def a_page_of(entries, *, from_sequence: int, next_from: int | None = None) -> dict[str, object]: + """The page a daemon serves for a read from `from_sequence`, holding those entries.""" + page = recorded_effect_page("example.effect", from_sequence=from_sequence) + kept = [dict(entry) for entry in entries if int(entry["sequence"]) >= from_sequence] + page["entries"] = kept + page["to_sequence"] = kept[-1]["sequence"] if kept else None + page["next_from"] = next_from + return page + + +class OnePageConnection: + """A chain that answers every read from one fixed list of entries. + + A double and nothing else: it holds no policy, verifies nothing and decides + nothing. What it gives a test is the one thing a canned daemon cannot — a + chain whose whole content is known, so that « this record was still there » + is an assertion rather than a hope. + """ + + server_credential = SimpleNamespace(uid=os.getuid()) + expected_uid = os.getuid() + verified = True + + def __init__(self, entries) -> None: + self.entries = list(entries) + self.reads = 0 + + def reconnect(self) -> None: + return None + + def close(self) -> None: + return None + + def read_evidence(self, scope: str, at: int, size: int): + self.reads += 1 + return Answered(a_page_of(self.entries, from_sequence=at), CONTRACT_GENERATION) + + +def test_a_record_consumed_out_of_order_leaves_an_earlier_one_reachable( + tmp_path: Path, +) -> None: + """F5. One cursor for every thread and every capability spends what it skips. + + Both records are in the chain before either consultation. The chain is + written in decision order and a program runs in execution order, and the + two are not the same order: a thread granted the earlier record can reach + its own effect after a thread granted the later one. Advancing one cursor + past the earlier record makes it permanently unreachable, and the effect it + covers is then reported as a finding — `ungoverned` — against a decision + that is sitting in the chain. + + What is spent is what was USED, never what was stepped over. + """ + connection = OnePageConnection([an_effect_entry(READING, 1), an_effect_entry(SPAWN, 2)]) + chain = harness.Chain(connection, "local", 0) + later = harness._consulted(chain, SPAWN) + earlier = harness._consulted(chain, READING) + assert later.matched == 2, later + assert earlier.matched == 1, earlier + + +def test_one_record_still_answers_for_one_effect_only(tmp_path: Path) -> None: + """The anti-vacuity of the test above: a spent record is spent. + + « Keep every record reachable » would satisfy the rule above and make one + recorded allow answer for every effect of its kind for ever, which is the + opposite defect and a worse one. + """ + connection = OnePageConnection([an_effect_entry(SPAWN, 1)]) + chain = harness.Chain(connection, "local", 0) + assert harness._consulted(chain, SPAWN).matched == 1 + assert harness._consulted(chain, SPAWN).matched is None + + +# --- 6. a walk that was interrupted establishes no absence ------------------- + + +def armed_watch() -> harness.Watch: + """A watch as it stands while the program's own code is running.""" + watch = harness.Watch() + watch.armed = watch.started = watch.ever_started = True + return watch + + +class InterruptedConnection(OnePageConnection): + """A chain that answers the first page, promises a second, and then cannot. + + The promise is the whole of it: the page itself says the walk is not over, + so « no matching record was found » is a statement about the part that was + read and about nothing else. + """ + + def read_evidence(self, scope: str, at: int, size: int): + self.reads += 1 + if at == 1: + return Answered( + a_page_of(self.entries, from_sequence=at, next_from=2), CONTRACT_GENERATION + ) + return reads.unreadable(ValueError("the second evidence page is unavailable")) + + +def test_an_interrupted_walk_is_not_an_absence_and_is_not_a_finding() -> None: + """F6. Some pages read, the walk not finished, and a negative finding published. + + `read_something` says a read was answered; it does not say the chain was + read to its end. With a continuation the client could not follow, a + matching record on the unread page is indistinguishable from no record at + all — and `ungoverned` is a finding this client made, which is the one + thing incomplete information cannot support. + + The effect is still aborted: what may not be claimed is the finding, not + the caution. + """ + pack = manifest.Point( + module="subprocess", + attribute="Popen", + capability=SPAWN, + digest=("args",), + audit_event="subprocess.Popen", + ) + item = harness.Watched("process-effects", pack) + chain = harness.Chain(InterruptedConnection([an_effect_entry(READING, 1)]), "local", 0) + with pytest.raises(RuntimeError) as aborted: + harness._judge(chain, item, armed_watch()) + assert harness.UNGOVERNED not in str(aborted.value), str(aborted.value) + assert item.refused is False, "a negative finding was published on an unfinished walk" + assert item.verdict != harness.UNGOVERNED + assert item.unjudged == 1, item.to_document() + + +def test_the_command_itself_reports_no_finding_over_an_unfinished_walk(tmp_path: Path) -> None: + """F6 again, through the shipped command, because the exit code is the claim. + + The daemon answers the first page, promises a second and then serves + something this client cannot read. That is not « the chain could not be + read » — a page WAS read — and it is not an absence either. The run carries + the incompleteness and answers 7, and the effect is aborted: the marker is + what says the abort happened before the spawn rather than after it. + + The third document is malformed rather than a status the canned daemon + cannot vary per answer; either way it is a read that arrived and is not an + answer, which is the fact this case turns on. + """ + pack = plant_spawn_pack(tmp_path) + marker = tmp_path / "the-effect-happened" + unfinished = recorded_effect_page(READING, next_from=2) + unreadable = {"entries": "not a page this client reads"} + routes = {"/scopes/": (200, [no_evidence_page(), unfinished, unreadable])} + with answering_by_path(tmp_path / "d.sock", routes) as address: + code, stdout, stderr = verified(pack, address, a_marking_tree(tmp_path, marker)) + assert code != 0, (stdout, stderr) + assert code == exit_codes.EXIT_COULD_NOT_CHECK, (stdout, stderr) + assert f"ungoverned {POINT}" not in stdout, "a finding was made on an unfinished walk" + assert f"{harness.UNJUDGED}: 1" in stdout + assert not marker.exists(), "the effect happened although nothing was established about it" + + +# --- 7. a report that never arrives asserts nothing -------------------------- + +#: How long this test waits before calling the run hung. The command's own +#: bound is `verify.HARNESS_TIMEOUT`, fifteen minutes, which is a bound and not +#: a test: a watchdog of its length would make the red run of this test +#: indistinguishable from a suite that had stopped. Generous against a loaded +#: machine, and two orders of magnitude under the bound it stands in for. +WATCHDOG = 45.0 + + +def test_a_target_holding_an_executor_still_reaches_its_own_report(tmp_path: Path) -> None: + """F8. The interpreter wakes idle workers before joining; this harness did not. + + `ThreadPoolExecutor` parks its workers on a queue and registers, through + `threading._register_atexit`, the callback that wakes them. The interpreter + runs those callbacks BEFORE it joins non-daemon threads. This harness + joined first and never reached the shutdown that would have woken them, so + a program whose library holds an executor — the ordinary shape of the + runtimes this chain exists to instrument — finished its work, returned, and + then hung until the command's fifteen-minute bound. + + A verification that does not end writes no report, and a report that never + arrives asserts nothing: this is the liveness half of the same rule. + """ + tree = a_tree( + tmp_path, + "from pool_library import pool\n\npool.submit(len, 'x').result()\nprint('main finished')\n", + ) + (tree / "pool_library.py").write_text( + "from concurrent.futures import ThreadPoolExecutor\n\npool = ThreadPoolExecutor(1)\n", + encoding="utf-8", + ) + pack = plant_spawn_pack(tmp_path) + routes = {"/scopes/": (200, no_evidence_page())} + with answering_by_path(tmp_path / "d.sock", routes) as address: + finished = subprocess.run( + [ + str(CONSOLE_SCRIPT), + "instrument", + "verify", + "--pack", + str(pack), + "--socket", + str(address), + "--scope", + "local", + "--ungoverned", + "--", + "app.py", + ], + cwd=str(tree), + capture_output=True, + text=True, + timeout=WATCHDOG, + ) + assert "main finished" in finished.stderr, (finished.stdout, finished.stderr) + assert f"not-exercised {POINT} events=0" in finished.stdout + assert finished.returncode == exit_codes.EXIT_COULD_NOT_CHECK, finished.stdout diff --git a/tests/test_verify_asks_every_effect.py b/tests/test_verify_asks_every_effect.py new file mode 100644 index 0000000..0211e81 --- /dev/null +++ b/tests/test_verify_asks_every_effect.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A verifying run asks the control plane for every effect it sees. + +`run` holds what it is granted: an identical later effect is answered by the +grant an earlier allow minted, with nothing asked and so nothing recorded +(article 10). The verifier's proof is one recorded decision for each effect it +saw, so under that cache an effect repeated within a grant's lifetime read as +ungoverned — stopped, and reported with status 6. The end-to-end case lives with +the control plane, whose daemon mints grants +(`test_a_verification_of_one_effect_repeated_is_clean`); this holds, here, that +the verifier hands the program over asking for every effect. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from contract_absence import contract_is_installed, skip_without_the_contract + + +def test_the_verifier_hands_the_program_over_asking_for_every_effect( + request, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + if not contract_is_installed(): + skip_without_the_contract(request, "the harness builds the contract's own profile") + from sayfirst_contract.transport.socket_client import SocketProfile + + from sayfirst_cli.instrument import harness, launch + + handed: dict[str, object] = {} + + def run(packs, profile, target, **options) -> int: # type: ignore[no-untyped-def] + handed.update(options) + return 0 + + monkeypatch.setattr(launch, "run", run) + configured = harness.Configuration( + packs=(), + profile=SocketProfile(str(tmp_path / "daemon.sock")), + principal=None, + governed=True, + report=tmp_path / "report.json", + outcome=tmp_path / "outcome.json", + ) + + class Watch: + def arm(self, *_: object) -> None: ... + + assert harness._run_the_target(configured, [], ["program.py"], Watch(), "run-token") == 0 + assert handed["hold_grants"] is False + assert handed["correlation"] == "run-token" + + +def test_a_governed_run_keeps_the_grants_article_10_gives_it( + request, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Only the proof asks for every effect; `run` answers a repeat from its grant.""" + if not contract_is_installed(): + skip_without_the_contract(request, "the launcher builds the boundary") + from sayfirst_contract.transport.socket_client import SocketProfile + + from sayfirst_cli.instrument import launch + + built: dict[str, object] = {} + + class Boundary: + def __init__(self, **options: object) -> None: + built.update(options) + + monkeypatch.setattr(launch, "Boundary", Boundary) + monkeypatch.setattr( + launch, "Engine", lambda: type("E", (), {"install": lambda *a, **k: None})() + ) + monkeypatch.setattr(launch, "_hand_off", lambda program, err, starting: 0) + program = tmp_path / "program.py" + program.write_text("pass\n", encoding="utf-8") + code = launch.run( + [], SocketProfile(str(tmp_path / "daemon.sock")), [str(program)], out=None, err=None + ) # type: ignore[arg-type] + assert code == 0 + assert built["hold_grants"] is True diff --git a/tests/test_verify_pairs_an_inner_spawn.py b/tests/test_verify_pairs_an_inner_spawn.py new file mode 100644 index 0000000..c518ee8 --- /dev/null +++ b/tests/test_verify_pairs_an_inner_spawn.py @@ -0,0 +1,241 @@ +# SPDX-License-Identifier: Apache-2.0 +"""An interposed call's own implementation is not a second path around the pack. + +The subprocess pack interposes `subprocess.Popen` and names the other ways this +interpreter creates a process, so the verifier counts an effect that took one +of them as an effect it could not judge. But `Popen` creates its own process +through two of those ways: `os.posix_spawn` whenever it can — for an executable +named with a directory, by default from Python 3.14 and with `close_fds=False` +before — and otherwise `_posixsubprocess.fork_exec`, which raises an event of +its own from Python 3.14. One governed spawn therefore raised two events, and +the run reported an effect it could not judge, and status 7, for a program that +did nothing around the pack. + +A pack now names such events as its point's `inner_events`, and the verifier +pairs one with the call it just judged when it is the very next event that +thread raises and it carries the argument vector that call was given. Anything +else is counted: a direct spawn of another command, a second inner event, an +inner event after anything else the thread raised. + +And the event Python 3.14 added is a path of its own: `multiprocessing` starts +its processes through it, and before the pack named it such a start reached the +world with nothing counted and the run still answered `0`. + +The end-to-end case against a real daemon is the control plane's +(`test_a_spawn_popen_makes_through_posix_spawn_is_one_effect`). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from canned_daemon import answering_by_path +from documents import no_evidence_page, recorded_effect_page +from governed_programs import instrument, plant_spawn_pack + +from sayfirst_cli import exit_codes +from sayfirst_cli.instrument import harness, manifest + +SHIPPED = Path(__file__).resolve().parents[1] / "src/sayfirst_cli/packs/subprocess" +SPAWN = "process.spawn" +POSIX_SPAWN = "os.posix_spawn" +FORK_EXEC = "_posixsubprocess.fork_exec" + +#: Python 3.14 is the first interpreter whose fork-and-exec raises an event at all. +ONLY_WHERE_FORK_EXEC_IS_AUDITED = pytest.mark.skipif( + sys.version_info < (3, 14), + reason="before Python 3.14 `_posixsubprocess.fork_exec` raises no audit event", +) + + +def _popen(args: object) -> harness.Judged: + """A judged `Popen`, as its audit event carries it: `(executable, args, cwd, env)`.""" + return harness.Judged(frozenset({POSIX_SPAWN, FORK_EXEC}), (None, args, None, None)) + + +# --- the pairing rule --------------------------------------------------------- + + +def test_the_inner_spawn_of_the_same_vector_is_the_same_act() -> None: + assert harness.the_same_act( + _popen(["/bin/true", "-x"]), POSIX_SPAWN, ("/bin/true", ["/bin/true", "-x"], None) + ) + + +def test_a_shell_command_is_paired_by_the_vector_popen_audits() -> None: + """`shell=True` is audited as the vector it spawns: `SHELL -c COMMAND`.""" + audited = ["/bin/sh", "-c", "echo hi"] + assert harness.the_same_act(_popen(audited), POSIX_SPAWN, ("/bin/sh", list(audited), None)) + + +def test_the_fork_exec_shape_is_paired_by_its_vector() -> None: + """`fork_exec` carries every place it will look for the program, then the vector.""" + assert harness.the_same_act( + _popen(["true"]), FORK_EXEC, ((b"/usr/bin/true", b"/bin/true"), ["true"]) + ) + + +def test_bytes_and_paths_are_read_as_the_names_they_are() -> None: + assert harness.the_same_act( + _popen([b"/bin/cat", Path("/tmp/x")]), POSIX_SPAWN, ("/bin/cat", ["/bin/cat", "/tmp/x"]) + ) + + +def test_another_command_is_another_act() -> None: + assert not harness.the_same_act( + _popen(["/bin/true"]), POSIX_SPAWN, ("/bin/rm", ["/bin/rm", "-rf", "/tmp/x"], None) + ) + + +def test_an_event_the_point_does_not_name_as_its_own_is_another_act() -> None: + assert not harness.the_same_act(_popen(["/bin/true"]), "os.system", ("/bin/true",)) + assert not harness.the_same_act( + harness.Judged(frozenset(), (None, ["/bin/true"], None, None)), + POSIX_SPAWN, + ("/bin/true", ["/bin/true"], None), + ) + + +def test_a_vector_that_is_not_a_list_or_a_tuple_is_not_read() -> None: + """Iterating anything else could run the program's code, or use up its argument.""" + consumed: list[str] = [] + + def vector(): + consumed.append("read") + yield "/bin/true" + + assert not harness.the_same_act( + _popen(["/bin/true"]), POSIX_SPAWN, ("/bin/true", vector(), None) + ) + assert consumed == [] + assert not harness.the_same_act(_popen(["/bin/true"]), POSIX_SPAWN, ("/bin/true",)) + + +# --- the declaration is the pack's -------------------------------------------- + + +def test_the_shipped_pack_names_popens_own_events_and_the_one_314_added() -> None: + pack = manifest.read_pack(SHIPPED) + inner = harness.inner_events(pack) + uninterposed = harness.uninterposed_events(pack) + assert set(inner[0]) == {POSIX_SPAWN, FORK_EXEC}, inner + assert FORK_EXEC in uninterposed[0], uninterposed + + +def _a_pack_declaring(root: Path, lines: str) -> manifest.Pack: + directory = plant_spawn_pack(root) + manifest_file = directory / manifest.MANIFEST_FILE + manifest_file.write_text(manifest_file.read_text(encoding="utf-8") + lines, encoding="utf-8") + return manifest.read_pack(directory) + + +def test_an_inner_event_must_be_one_the_point_names_as_uninterposed(tmp_path: Path) -> None: + """Pairing only stops a named event being counted; naming only one side declares nothing.""" + pack = _a_pack_declaring( + tmp_path, + f'{harness.UNINTERPOSED} = ["os.system"]\n{harness.INNER} = ["{POSIX_SPAWN}"]\n', + ) + with pytest.raises(manifest.PackInvalid, match=harness.INNER): + harness.inner_events(pack) + + +@pytest.mark.parametrize("declared", ["[]", '"os.posix_spawn"', '[""]', "[1]"]) +def test_a_declaration_this_reader_cannot_read_is_refused(tmp_path: Path, declared: str) -> None: + pack = _a_pack_declaring( + tmp_path, f'{harness.UNINTERPOSED} = ["{POSIX_SPAWN}"]\n{harness.INNER} = {declared}\n' + ) + with pytest.raises(manifest.PackInvalid, match=harness.INNER): + harness.inner_events(pack) + + +def test_a_point_that_names_none_pairs_none(tmp_path: Path) -> None: + pack = _a_pack_declaring(tmp_path, f'{harness.UNINTERPOSED} = ["{POSIX_SPAWN}"]\n') + assert harness.inner_events(pack) == {} + + +# --- the run, against the shipped pack ---------------------------------------- + + +def _verified(tmp_path: Path, program: str, *, reads: int = 1) -> tuple[int, str, str]: + """`verify --ungoverned` of one program under the shipped subprocess pack.""" + tree = tmp_path / "tree" + tree.mkdir() + (tree / "app.py").write_text(program, encoding="utf-8") + pages = [no_evidence_page()] + [recorded_effect_page(SPAWN)] * reads + with answering_by_path(tmp_path / "d.sock", {"/scopes/": (200, pages)}) as address: + finished = instrument( + "verify", + "--pack", + str(SHIPPED), + "--socket", + str(address), + "--scope", + "local", + "--ungoverned", + "--", + "app.py", + cwd=tree, + ) + return finished.returncode, finished.stdout, finished.stderr + + +def test_a_spawn_popen_makes_through_posix_spawn_is_one_effect(tmp_path: Path) -> None: + code, out, err = _verified( + tmp_path, + "import subprocess\nsubprocess.run(['/bin/true'], check=True, close_fds=False)\n", + ) + assert code == 0, (out, err) + assert harness.UNJUDGED not in out, out + + +def test_a_spawn_popen_makes_through_fork_exec_is_one_effect(tmp_path: Path) -> None: + """A working directory keeps `Popen` off `posix_spawn`, on every interpreter.""" + code, out, err = _verified( + tmp_path, "import subprocess\nsubprocess.run(['/bin/true'], check=True, cwd='/')\n" + ) + assert code == 0, (out, err) + assert harness.UNJUDGED not in out, out + + +@ONLY_WHERE_FORK_EXEC_IS_AUDITED +def test_a_direct_spawn_after_a_governed_one_is_counted_even_of_the_same_command( + tmp_path: Path, +) -> None: + """The pairing belongs to the event raised next, never to one raised later. + + `Popen` given a working directory raises its own `fork_exec`, which takes the + pairing; the direct `posix_spawn` that follows is a second process, created + past the pack, and it is counted though it runs the very same command. + """ + code, out, err = _verified( + tmp_path, + "import os\nimport subprocess\n" + "subprocess.run(['/bin/true'], check=True, cwd='/')\n" + "pid = os.posix_spawn('/bin/true', ['/bin/true'], os.environ)\n" + "print('second process status:', os.waitpid(pid, 0)[1], flush=True)\n", + ) + assert "second process status: 0" in out + err, (out, err) + assert code == exit_codes.EXIT_COULD_NOT_CHECK, (out, err) + assert f"{harness.UNJUDGED}: 1" in out, out + + +@ONLY_WHERE_FORK_EXEC_IS_AUDITED +def test_a_process_multiprocessing_starts_is_counted(tmp_path: Path) -> None: + """The start method `multiprocessing` uses by default on Linux from Python 3.14.""" + code, out, err = _verified( + tmp_path, + "import multiprocessing\n" + "import os\n" + "\n" + "if __name__ == '__main__':\n" + " child = multiprocessing.get_context('forkserver').Process(target=os.getpid)\n" + " child.start()\n" + " child.join()\n" + " print('child exit:', child.exitcode, flush=True)\n", + reads=0, + ) + assert "child exit: 0" in out + err, (out, err) + assert code == exit_codes.EXIT_COULD_NOT_CHECK, (out, err) + assert f"{harness.UNJUDGED}:" in out, out