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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ declares; a release with no section fails the gate.

## Unreleased

## 0.3.1

- **`instrument verify` says how a run ended when the program raised, too.** The harness said
its own ending only for a program that returned, so a program that ended on an exception before
a line of it was seen — a script that does not compile, a module whose bytecode another Python
wrote — left no ending behind, and the command answered `4` and « the verification did not run
to a conclusion »: « the control plane could not be asked », about a plane that had answered.
It now answers `7`, « the verifier never saw the program's own code start », naming the
exception the program ended on; the program's own traceback is still printed. For the same
reason, a program that caught the abort of an unreadable chain and then raised an exception of
its own now gets the chain's ending (`4`, the transport's own problem code and sentence) rather
than the generic one.
- The contract and the boundary move to 0.3.1, released with this client.
- A `python` the shell cannot find is refused as before (64), and the refusal now names
the `python3` on the same PATH.
- Every program the README and the quickstart run is spelled `python3 …`; the
quickstart starts the daemon again before section 7, shows a closed epoch verified,
and says how long a rejection stands.

## 0.3.0

- **`--socket` is optional for a per-user profile**, on every verb that opens a connection. Given
Expand Down
248 changes: 174 additions & 74 deletions QUICKSTART.md

Large diffs are not rendered by default.

34 changes: 18 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ anything that matters: *may I do this, with these arguments, as this account?*
the boundary (`sayfirst-boundary`) holds the grant for exactly one execution.
- **A person is in the loop by construction, not by dashboard.** A suspension
is a wait. `sayfirst approvals approve` ends it once, the deadline comes from
the policy, and a rejection is final.
the policy, and a rejection stands until that deadline, or until the daemon
restarts.
- **Every decision leaves a record**, and `sayfirst evidence export` saves a
bundle a third party verifies offline, with the contract alone.
- **The evidence is honest about itself.** An export of an epoch the daemon has
Expand All @@ -39,7 +40,7 @@ flowchart LR
```console
$ uv tool install sayfirst-cli --with-executables-from sayfirst-control-plane --with-executables-from sayfirstd
$ sayfirst-daemon up --quickstart
$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py
$ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py
```

The first installs the client (`sayfirst`), the daemon (`sayfirst-daemon`) and
Expand All @@ -48,7 +49,7 @@ install` takes one package, so the other two ride on `--with-executables-from`.
The second writes a readable starter policy and a per-user configuration under
`~/.sayfirst/quickstart/` **if they are not there**, starts the real daemon in
the background, and says « ready » only once that daemon has answered. The third
runs a program you already have, under the `python` you named, with every
runs a program you already have, under the `python3` you named, with every
process it starts asked about first:

```console
Expand All @@ -60,7 +61,7 @@ policy: ~/.sayfirst/quickstart/policy.toml
evidence: ~/.sayfirst/quickstart/evidence
integrity grade: observability (the caller can write the store; the chain detects accidental corruption only)
…
$ sayfirst instrument run --pack subprocess --scope local -- python my_agent.py
$ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py
hello
```

Expand All @@ -82,7 +83,7 @@ restarted — and running `up --quickstart` again never writes over it.

```console
$ sayfirstd status # what the daemon says about itself
$ sayfirst instrument verify --pack subprocess --scope local -- python my_agent.py
$ sayfirst instrument verify --pack subprocess --scope local -- python3 my_agent.py
$ sayfirst-daemon down # stops the daemon `up` started, and only that one
```

Expand All @@ -92,7 +93,7 @@ command on that page was run, in that order, before it was written down.

### What the index holds

Everything the three commands above need is on the Python index at 0.3.0:
Everything the three commands above need is on the Python index at 0.3.1:
`sayfirst-cli`, the `sayfirst-contract` it speaks and the `sayfirst-boundary` a
governed program holds its grants in, and the control plane's
`sayfirst-control-plane` and `sayfirstd`. The first command installs all five
Expand All @@ -119,12 +120,13 @@ verified, and one answer rendered as it was given: allow, deny or suspend, and
**It puts the boundary in front of somebody else's program.** `sayfirst
instrument run --pack PACK … -- <program>` runs a program with the named effects
asked about first, reversibly and writing nothing anywhere; a program spelled
`python app.py` is handed, whole command and all, to the interpreter that was
named, so it keeps its own environment's dependencies; `instrument verify`
runs it again under the interpreter's own audit hook and proves, from that and
the scope's evidence chain alone, that every effect of a named kind was preceded
by a decision; `instrument apply` is reserved for the committed code
modification and refuses, saying so. `sayfirst packs list` prints the packs this
`python3 app.py` (or `python app.py` inside an activated environment) is
handed, whole command and all, to the interpreter that was named, so it keeps
its own environment's dependencies; `instrument verify` runs it again under the
interpreter's own audit hook and proves, from that and the scope's evidence
chain alone, that every effect of a named kind was preceded by a decision;
`instrument apply` is reserved for the committed code modification and refuses,
saying so. `sayfirst packs list` prints the packs this
distribution ships — `database`, `http-client`, `subprocess` — each of which
`--pack` takes by that name, while a pack of your own is a directory spelled
with a separator, `--pack ./own-pack` ([`docs/PACKS.md`](docs/PACKS.md)). A program whose effects are
Expand Down Expand Up @@ -224,7 +226,7 @@ contract is built from a checkout of the control plane's repository, at the tag
this client pins:

```console
$ SAYFIRST_CONTRACT_SOURCE=../sayfirst-control-plane SAYFIRST_CONTRACT_REF=v0.3.0 ./scripts/gate.sh
$ SAYFIRST_CONTRACT_SOURCE=../sayfirst-control-plane SAYFIRST_CONTRACT_REF=v0.3.1 ./scripts/gate.sh
```

The workflow does the same and carries no credential of any kind: a fork can
Expand All @@ -251,9 +253,9 @@ GitHub's private reporting, never a public issue — [`LICENSE`](LICENSE),

## Status

**0.2.0 is the first public release**, 2026-09-17. What is *not* here is named
too, because a surface a reader assumes is an overclaim: `connect`, `profile`,
`whoami`, `integrate` and `version` are in
**0.2.0 is the first public release**, 2026-09-17. 0.3.1 is the current one.
What is *not* here is named too, because a surface a reader assumes is an
overclaim: `connect`, `profile`, `whoami`, `integrate` and `version` are in
[`docs/PARTITION.md`](docs/PARTITION.md) and none of them exists here.

Next, and only what a document in this tree already says: the `evidence` grade of
Expand Down
22 changes: 13 additions & 9 deletions docs/PACKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,19 @@ and why it is measured rather than guarded.

A run can also report **no verdict at all**: the chain could not be read —
before the program started, or while it ran — and it exits `4`; or the
verifier never saw the program's own code start (a target the interpreter runs
from bytecode with no source beside it), and it exits `7`, because the plane
was asked and what could not be done was the watching. None of these is a
verdict about the program, and none is rendered as one; each carries its own
problem code and its own sentence, because « this run established an
absence », « this run never began watching » and « the chain could not be
read » are different facts. The harness says which of its own endings happened
in a file of its own, so the answer is never chosen by the number the verified
program happened to exit with.
verifier never saw the program's own code start, and it exits `7`, because the
plane was asked and what could not be done was the watching. The second has
two causes and the sentence names which: a target the interpreter runs from
bytecode with no source beside it, which ran unseen; or a program that ended on
an exception before a line of it was seen — a script that does not compile, a
module whose bytecode another Python wrote — named with the exception it ended
on. None of these is a verdict about the program, and none is rendered as one;
each carries its own problem code and its own sentence, because « this run
established an absence », « this run never began watching » and « the chain
could not be read » are different facts. The harness says which of its own
endings happened in a file of its own, whether the program returned or raised,
so the answer is never chosen by the number the verified program happened to
exit with.
A chain read the control plane **refused** — a scope it will not read, a
principal it does not admit — exits `3` instead, as it does for every other
command of this client: the plane was asked, and it said no. The harness writes
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
[project]
name = "sayfirst-cli"
version = "0.3.0"
version = "0.3.1"
description = "The product command-line interface of the sayfirst control plane"
readme = "README.md"
requires-python = ">=3.12,<3.15"
Expand All @@ -20,7 +20,7 @@ classifiers = [
# promise made here — it is measured after an install. `sayfirst-boundary` joins
# it because a governed program holds a grant in it (article 10); it depends on
# the contract only, and it is not the server (article 14).
dependencies = ["sayfirst-contract==0.3.0", "sayfirst-boundary==0.3.0"]
dependencies = ["sayfirst-contract==0.3.1", "sayfirst-boundary==0.3.1"]

# The public repository this distribution sends a reader to, created fresh in
# the act that published it (2026-09-17) under the names the operator chose for
Expand All @@ -41,7 +41,7 @@ sayfirst = "sayfirst_cli.main:run"
dev = [
"pytest==8.4.1",
"ruff==0.12.12",
"sayfirst-contract[stub]==0.3.0",
"sayfirst-contract[stub]==0.3.1",
]

[build-system]
Expand Down
77 changes: 69 additions & 8 deletions src/sayfirst_cli/instrument/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,21 @@

#: What a run says when it never saw the program's own code begin. It is not a
#: verdict and it is not `not-exercised`: nothing was watched, so nothing about
#: the program was established — not even an absence.
#: the program was established — not even an absence. WHY it never began is said
#: beside it and not in it, because there are two answers and they are opposite
#: facts about the program: it ran without being seen (`RAN_UNSEEN`), or it
#: ended on an exception before this run saw a line of it — a script that does
#: not compile, bytecode another Python wrote — and may never have run at all.
NEVER_STARTED: Final[str] = (
"the verifier never saw the program's own code start, so nothing about this program "
"was watched and no verdict is reported: the interpreter ran it from a code object "
"that carries no file this run resolved, which is what a module shipped as bytecode "
"with no source beside it looks like"
"was watched and no verdict is reported"
)

#: Why, for a program that ended without raising: the interpreter ran code this
#: run could not recognise as the program's own.
RAN_UNSEEN: Final[str] = (
"the interpreter ran it from a code object that carries no file this run resolved, "
"which is what a module shipped as bytecode with no source beside it looks like"
)

#: The configuration member naming the file this harness says its own ending in.
Expand Down Expand Up @@ -413,8 +422,8 @@ class Watch:
that matters. A target whose executed code object does not carry a file the
lookup resolved — a module shipped as bytecode with no source beside it —
leaves this gate shut, and a run whose gate never opened concludes nothing
at all: it says so and answers « could not read », rather than reporting
the absence it never established (`_prove` says where).
at all: it says so and answers « could not check », rather than reporting
the absence it never established (`_say_how_the_run_ended` says where).

**Not inside a consultation.** A consultation reads the chain, and those
reads are themselves acts the interpreter reports. Per thread, so an event
Expand Down Expand Up @@ -923,6 +932,7 @@ def say(outcome: str, detail: str, *, problem: Problem | None = None) -> None:
sys.addaudithook(_consulting(chain, by_event, uninterposed, watch, inner))
ending: int | None = None
refused_the_invocation = False
raised: BaseException | None = None
try:
ending = _run_the_target(configured, packs, target, watch, correlation)
except HarnessMisuse as misuse:
Expand Down Expand Up @@ -950,6 +960,17 @@ def say(outcome: str, detail: str, *, problem: Problem | None = None) -> None:
# the `finally`, from the watch rather than from here, so that a target
# which caught the exception cannot bury it.
pass
except BaseException as failure: # held, said, and raised again below
# The program's own ending, or the interpreter's refusal to begin it — a
# script that does not compile, bytecode another Python wrote. It used
# to leave this function before anything below said how the run ended,
# and the command read the missing outcome as « the harness never
# reached its own first statement »: exit 4, « could not ask », about a
# plane that had been asked and had answered. So it is held here, the
# run's ending is said below exactly as for a program that returned, and
# it is raised again last: the traceback the interpreter renders is the
# program's diagnostic, and saying an ending is no reason to swallow it.
raised = failure
finally:
# The program is not done because its main module returned, so the
# watch stays armed until it is — its own threads run out and its own
Expand All @@ -966,6 +987,26 @@ def say(outcome: str, detail: str, *, problem: Problem | None = None) -> None:
):
with watch.ours():
_write_report(configured, packs, watched, head, ending, connection)
answered = _say_how_the_run_ended(say, watch, target, raised)
if raised is not None:
raise raised
return answered


def _say_how_the_run_ended(
say: Callable[..., None],
watch: Watch,
target: Sequence[str],
raised: BaseException | None,
) -> int:
"""Which of this harness's own endings happened, said whichever way the program ended.

`raised` is what the program ended on, or `None` for a program that
returned. It changes one thing: WHY a gate that never opened stayed shut.
Everything else is the same ending for a program that raised as for one
that returned — which is the point, since the command reads the ending and
never the program's number.
"""
if watch.unreadable is not None:
say(
CHAIN_UNREADABLE_DURING,
Expand All @@ -983,8 +1024,13 @@ def say(outcome: str, detail: str, *, problem: Problem | None = None) -> None:
# `not-exercised`, byte for byte — so a run that watched nothing at all
# read exactly like a run that established an absence, which is the
# distinction the rest of this file is fastidious about (article 2).
say(GATE_NEVER_OPENED, " ".join(target))
sys.stderr.write(f"{NEVER_STARTED}\n")
named = " ".join(target)
if raised is None:
why = f"{RAN_UNSEEN}: {named}"
else:
why = f"{named} ended on {_ended_on(raised)}"
say(GATE_NEVER_OPENED, why)
sys.stderr.write(f"{NEVER_STARTED}: {why}\n")
return exit_codes.EXIT_COULD_NOT_CHECK
# No detail: the only path that reads this one is a report that will not
# parse, and the report lives in a directory the command removes before the
Expand All @@ -993,6 +1039,21 @@ def say(outcome: str, detail: str, *, problem: Problem | None = None) -> None:
return exit_codes.EXIT_ALLOW


def _ended_on(raised: BaseException) -> str:
"""The exception a program ended on: its class's name, and its message when it has one.

The message is the program's own text, which is the program's to get wrong: a `__str__`
that raises must not cost the run the ending it is about to say, so it
costs only the message.
"""
name = type(raised).__name__
try:
message = str(raised)
except Exception: # any failure of the program's own text
return name
return f"{name}: {message}" if message else name


def _wait_for_the_program(chain: Chain) -> None:
"""Run out everything the program left to do, before anything is concluded.

Expand Down
15 changes: 13 additions & 2 deletions src/sayfirst_cli/instrument/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,12 +298,23 @@ def selection(target: Sequence[str]) -> tuple[str, list[str]] | None:


def locate(word: str) -> str:
"""The interpreter a word names, as an absolute path, or the misuse that it names none."""
"""The interpreter a word names, as an absolute path, or the misuse that it names none.

Nothing is substituted: a word the shell would not run is not run. But a stock
Debian or Ubuntu has `python3` and no `python`, so when the one named is absent and
`python3` is on the same PATH the refusal says so.
"""
found = shutil.which(word)
if found is None:
hint = ""
if word != "python3" and shutil.which("python3") is not None:
hint = (
f" — this PATH has python3: name it (-- python3 …), or activate the "
f"environment that provides {word!r}"
)
raise launch.LaunchMisuse(
f"this launcher found no interpreter at {word!r}: a target whose first word is "
f"spelled like one is run by that interpreter, and there is none to run it"
f"spelled like one is run by that interpreter, and there is none to run it{hint}"
)
return os.path.abspath(found)

Expand Down
Loading
Loading