sayfirst-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sayfirst_cli/__init__.py +2 -0
- sayfirst_cli/approvals.py +127 -0
- sayfirst_cli/ask.py +167 -0
- sayfirst_cli/evidence.py +668 -0
- sayfirst_cli/exit_codes.py +88 -0
- sayfirst_cli/explain.py +35 -0
- sayfirst_cli/instrument/__init__.py +14 -0
- sayfirst_cli/instrument/commands.py +191 -0
- sayfirst_cli/instrument/engine.py +396 -0
- sayfirst_cli/instrument/harness.py +1162 -0
- sayfirst_cli/instrument/launch.py +417 -0
- sayfirst_cli/instrument/manifest.py +409 -0
- sayfirst_cli/instrument/verify.py +553 -0
- sayfirst_cli/main.py +78 -0
- sayfirst_cli/packs/__init__.py +10 -0
- sayfirst_cli/packs/database/NOTE.md +4 -0
- sayfirst_cli/packs/database/interpose.py +85 -0
- sayfirst_cli/packs/database/pack.toml +13 -0
- sayfirst_cli/packs/http-client/NOTE.md +4 -0
- sayfirst_cli/packs/http-client/interpose.py +72 -0
- sayfirst_cli/packs/http-client/pack.toml +13 -0
- sayfirst_cli/packs/subprocess/NOTE.md +4 -0
- sayfirst_cli/packs/subprocess/interpose.py +90 -0
- sayfirst_cli/packs/subprocess/pack.toml +13 -0
- sayfirst_cli/packs_cmd.py +115 -0
- sayfirst_cli/pages.py +87 -0
- sayfirst_cli/reads.py +254 -0
- sayfirst_cli/render.py +138 -0
- sayfirst_cli/trace.py +88 -0
- sayfirst_cli-0.2.0.dist-info/METADATA +297 -0
- sayfirst_cli-0.2.0.dist-info/RECORD +35 -0
- sayfirst_cli-0.2.0.dist-info/WHEEL +4 -0
- sayfirst_cli-0.2.0.dist-info/entry_points.txt +2 -0
- sayfirst_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- sayfirst_cli-0.2.0.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The proof harness: what the interpreter reported, against what the plane recorded.
|
|
3
|
+
|
|
4
|
+
Layer 3 of the instrumentation chain, and the one piece of it that trusts
|
|
5
|
+
nothing else in the chain. It watches the interpreter's own audit events and
|
|
6
|
+
reads the scope's evidence, and it holds no other source: no bookkeeping of the
|
|
7
|
+
engine's, no answer the boundary kept, nothing a pack said it had done. A proof
|
|
8
|
+
that trusts the thing it is proving is not a proof, which is why this file's
|
|
9
|
+
imports of the layers below it are the ones that RUN the program and read what
|
|
10
|
+
a pack declares — never the ones that would tell it what happened. (It names
|
|
11
|
+
the engine for one thing only: the exception class the engine raises out of the
|
|
12
|
+
program's own import, which is a refusal of the invocation and not a report of
|
|
13
|
+
anything that happened.)
|
|
14
|
+
|
|
15
|
+
**Why a process of its own.** The hook that stops an effect raises, and a hook
|
|
16
|
+
installed on this interpreter can never be removed. That irreversibility
|
|
17
|
+
disqualifies an audit hook as the engine — article 9 asks the primary mode to
|
|
18
|
+
be reversible — and is exactly what a proof harness wants. So the hook lives
|
|
19
|
+
and dies with this process, and the command that spawns it keeps none of it.
|
|
20
|
+
|
|
21
|
+
**What it concludes, in three words and no fourth.** `governed`: the effect
|
|
22
|
+
happened and a decision preceded it. `ungoverned`: the effect happened and no
|
|
23
|
+
decision preceded it. `not-exercised`: this run never walked the path. The
|
|
24
|
+
third is never a pass, and this file never turns it into one — it counts events
|
|
25
|
+
and writes them down; the command reads the report and decides the exit.
|
|
26
|
+
|
|
27
|
+
**One thing more is written down, and it is a COUNT rather than a fourth
|
|
28
|
+
word.** An event can arrive that this proof cannot judge at all: its argument
|
|
29
|
+
names one of the program's own start files after the program has begun, which
|
|
30
|
+
is the one shape `Watch` cannot tell from the hand-off's own reading of the
|
|
31
|
+
same file. Such an event is counted on the point it would have been judged
|
|
32
|
+
against and published as `unjudged`, beside a verdict that stays about the
|
|
33
|
+
events that WERE judged. It is not a verdict and not one of the three words:
|
|
34
|
+
every way of spelling it as one says something the run did not measure. What it
|
|
35
|
+
does is refuse the run a pass — `verify` cannot answer 0 while any count is
|
|
36
|
+
non-zero — which is the direction article 2 requires, and the direction this
|
|
37
|
+
rule failed in for as long as the same events were silently dropped.
|
|
38
|
+
|
|
39
|
+
**A fourth thing can happen, and it is not a verdict.** The chain may be
|
|
40
|
+
unreadable while the program runs. « No record exists » and « this client could
|
|
41
|
+
not read the chain » are different facts, and `exit_codes.py` keeps them apart:
|
|
42
|
+
a consultation in which not one read was answered ends the run with « could not
|
|
43
|
+
read » and no findings at all, never with `ungoverned`. The effect is still
|
|
44
|
+
aborted, because an effect whose governance could not be established is not one
|
|
45
|
+
this harness may let through — but nothing is concluded about the program.
|
|
46
|
+
|
|
47
|
+
**The wait, and why it is not a caller's to set.** Article 10 makes the chain
|
|
48
|
+
write asynchronous, so an effect's record may arrive after the effect was
|
|
49
|
+
allowed. The poll therefore has patience, and the patience is fixed here: a
|
|
50
|
+
verifier whose patience a caller could set to nothing would report `ungoverned`
|
|
51
|
+
for a daemon that was merely slow, which is a false finding rather than a
|
|
52
|
+
configurable one.
|
|
53
|
+
|
|
54
|
+
**Whose act was it.** `Watch` answers that question and nothing else, and it
|
|
55
|
+
exists because this harness runs inside the process it is watching: its own
|
|
56
|
+
chain reads, its own report write and the interpreter's own reading of the
|
|
57
|
+
program's file all raise the events a pack may have named. `Watch` says what
|
|
58
|
+
each rule excludes and why.
|
|
59
|
+
|
|
60
|
+
**Nothing here names a library.** The events it watches for are read off the
|
|
61
|
+
manifests the invocation designated — `tests/test_engine_is_agnostic.py` binds
|
|
62
|
+
this file for the same reason it binds the engine: a harness that knew one
|
|
63
|
+
library's event name would be the special case article 4 forbids, arriving
|
|
64
|
+
inside the one component whose whole job is to be impartial about what it
|
|
65
|
+
watches.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import atexit
|
|
71
|
+
import json
|
|
72
|
+
import os
|
|
73
|
+
import sys
|
|
74
|
+
import threading
|
|
75
|
+
import time
|
|
76
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
77
|
+
from contextlib import contextmanager, suppress
|
|
78
|
+
from dataclasses import dataclass, field
|
|
79
|
+
from pathlib import Path
|
|
80
|
+
from typing import Final
|
|
81
|
+
|
|
82
|
+
from sayfirst_contract.client import Answered, Result
|
|
83
|
+
from sayfirst_contract.problems import Problem, ProblemCode
|
|
84
|
+
from sayfirst_contract.transport.socket_client import (
|
|
85
|
+
ProfileMisuse,
|
|
86
|
+
SocketClientProblem,
|
|
87
|
+
SocketProfile,
|
|
88
|
+
VerifiedConnection,
|
|
89
|
+
connect,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
from .. import exit_codes, pages, reads, render
|
|
93
|
+
from . import engine, launch, manifest
|
|
94
|
+
|
|
95
|
+
#: An effect happened and a decision preceded it.
|
|
96
|
+
GOVERNED: Final[str] = "governed"
|
|
97
|
+
|
|
98
|
+
#: An effect happened and no decision preceded it.
|
|
99
|
+
UNGOVERNED: Final[str] = "ungoverned"
|
|
100
|
+
|
|
101
|
+
#: This run never walked the path. Never rendered as a pass (article 2).
|
|
102
|
+
NOT_EXERCISED: Final[str] = "not-exercised"
|
|
103
|
+
|
|
104
|
+
#: The closed vocabulary, so that a fourth word cannot be spelled by accident.
|
|
105
|
+
VERDICTS: Final[tuple[str, ...]] = (GOVERNED, UNGOVERNED, NOT_EXERCISED)
|
|
106
|
+
|
|
107
|
+
#: Whose act one event was. Three answers and not two, which is what the false
|
|
108
|
+
#: all-clear this rule was corrected for cost: an event whose argument names one of the
|
|
109
|
+
#: program's own start files, arriving after the gate opened, is neither the
|
|
110
|
+
#: hand-off's act nor one this proof can judge — and folding it into « not the
|
|
111
|
+
#: program's » hid an unjudged effect behind a judged one. These are NOT
|
|
112
|
+
#: verdicts and are deliberately not in `VERDICTS`: a verdict is about a point
|
|
113
|
+
#: across a whole run, and the vocabulary above stays three words wide.
|
|
114
|
+
THE_PROGRAMS: Final[str] = "the program's"
|
|
115
|
+
THE_HAND_OFFS: Final[str] = "the hand-off's"
|
|
116
|
+
NOT_JUDGED: Final[str] = "not judged"
|
|
117
|
+
|
|
118
|
+
#: What the report calls the count of events a point could not judge. A COUNT
|
|
119
|
+
#: and never a verdict — the run carries both, because « one effect on this
|
|
120
|
+
#: point was governed » and « another was not judged » are two facts and one
|
|
121
|
+
#: word cannot carry them.
|
|
122
|
+
UNJUDGED: Final[str] = "unjudged"
|
|
123
|
+
|
|
124
|
+
#: The kind of chain entry that records an effect, and the outcome that let it
|
|
125
|
+
#: happen. Both are the plane's own words, read and never translated.
|
|
126
|
+
EFFECT: Final[str] = "effect"
|
|
127
|
+
ALLOW: Final[str] = "allow"
|
|
128
|
+
|
|
129
|
+
#: How long one consultation waits for the record of an effect to appear.
|
|
130
|
+
#: Article 10 writes the chain asynchronously; see the module docstring for why
|
|
131
|
+
#: this is not an option.
|
|
132
|
+
CHAIN_WAIT: Final[float] = 2.0
|
|
133
|
+
|
|
134
|
+
#: How long between two poll cycles. Short enough that the wait above is spent
|
|
135
|
+
#: waiting rather than sleeping.
|
|
136
|
+
POLL_INTERVAL: Final[float] = 0.05
|
|
137
|
+
|
|
138
|
+
#: How many entries one read asks for, which is the daemon's own maximum.
|
|
139
|
+
PAGE_SIZE: Final[int] = 100
|
|
140
|
+
|
|
141
|
+
#: What separates this harness's own arguments from the program's.
|
|
142
|
+
SEPARATOR: Final[str] = "--"
|
|
143
|
+
|
|
144
|
+
#: What a run says when it never saw the program's own code begin. It is not a
|
|
145
|
+
#: verdict and it is not `not-exercised`: nothing was watched, so nothing about
|
|
146
|
+
#: the program was established — not even an absence.
|
|
147
|
+
NEVER_STARTED: Final[str] = (
|
|
148
|
+
"the verifier never saw the program's own code start, so nothing about this program "
|
|
149
|
+
"was watched and no verdict is reported: the interpreter ran it from a code object "
|
|
150
|
+
"that carries no file this run resolved, which is what a module shipped as bytecode "
|
|
151
|
+
"with no source beside it looks like"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
#: The configuration member naming the file this harness says its own ending in.
|
|
155
|
+
OUTCOME_FILE_MEMBER: Final[str] = "outcome"
|
|
156
|
+
|
|
157
|
+
#: The findings were written; whatever else is wrong with them is the reader's
|
|
158
|
+
#: to say.
|
|
159
|
+
REPORTED: Final[str] = "reported"
|
|
160
|
+
|
|
161
|
+
#: The invocation was refused and the program never started.
|
|
162
|
+
INVOCATION_REFUSED: Final[str] = "invocation_refused"
|
|
163
|
+
|
|
164
|
+
#: The chain could not be read before the program started, so nothing was ever
|
|
165
|
+
#: watched.
|
|
166
|
+
CHAIN_UNREADABLE_BEFORE: Final[str] = "chain_unreadable_before"
|
|
167
|
+
|
|
168
|
+
#: The chain could not be read while the program ran, so an effect was aborted
|
|
169
|
+
#: on an unknown and nothing about the program is claimed.
|
|
170
|
+
CHAIN_UNREADABLE_DURING: Final[str] = "chain_unreadable_during"
|
|
171
|
+
|
|
172
|
+
#: The gate never opened: the interpreter never reported one of the program's
|
|
173
|
+
#: own code objects, so this proof never began watching.
|
|
174
|
+
GATE_NEVER_OPENED: Final[str] = "gate_never_opened"
|
|
175
|
+
|
|
176
|
+
#: The closed vocabulary of this harness's own endings, so a sixth cannot be
|
|
177
|
+
#: spelled by accident. They are NOT verdicts: a verdict is about a point across
|
|
178
|
+
#: a run, and these are about the run itself.
|
|
179
|
+
OUTCOMES: Final[tuple[str, ...]] = (
|
|
180
|
+
REPORTED,
|
|
181
|
+
INVOCATION_REFUSED,
|
|
182
|
+
CHAIN_UNREADABLE_BEFORE,
|
|
183
|
+
CHAIN_UNREADABLE_DURING,
|
|
184
|
+
GATE_NEVER_OPENED,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
#: The members the configuration must carry, and the shape each has to have.
|
|
188
|
+
#: Read through a table so that « declared as the wrong thing » and « not
|
|
189
|
+
#: declared at all » take one path to one refusal.
|
|
190
|
+
CONFIGURED: Final[tuple[str, ...]] = (
|
|
191
|
+
"packs",
|
|
192
|
+
"socket",
|
|
193
|
+
"mode",
|
|
194
|
+
"daemon_user",
|
|
195
|
+
"scope",
|
|
196
|
+
"principal",
|
|
197
|
+
"governed",
|
|
198
|
+
"report",
|
|
199
|
+
OUTCOME_FILE_MEMBER,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class HarnessMisuse(ValueError):
|
|
204
|
+
"""This harness cannot run what it was asked to, and nothing was proven."""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ChainUnreadable(RuntimeError):
|
|
208
|
+
"""An effect could not be verified, because the chain could not be read at all.
|
|
209
|
+
|
|
210
|
+
Raised into the target, so the effect is aborted rather than let through on
|
|
211
|
+
an unknown. It is deliberately NOT the same exception as an ungoverned
|
|
212
|
+
effect, and it produces no finding: the problem is carried on the watch and
|
|
213
|
+
the run ends with « could not read ». Publishing exit 6 — "a finding this
|
|
214
|
+
client made" — for a plane that was never read would be exactly the
|
|
215
|
+
collision between a finding and an unanswerable question that
|
|
216
|
+
`exit_codes.py` exists to prevent (articles 1 and 2).
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class Watched:
|
|
222
|
+
"""One interposition point, and what this run observed about it."""
|
|
223
|
+
|
|
224
|
+
pack: str
|
|
225
|
+
point: manifest.Point
|
|
226
|
+
events: int = 0
|
|
227
|
+
refused: bool = False
|
|
228
|
+
#: Events on this point that this run could not judge, because an argument
|
|
229
|
+
#: named one of the program's own start files after the gate had opened —
|
|
230
|
+
#: the one shape `Watch` cannot tell from the hand-off's own reading of the
|
|
231
|
+
#: same file. Counted rather than dropped, and carried beside the verdict
|
|
232
|
+
#: rather than folded into it: the command reads it and cannot answer 0.
|
|
233
|
+
unjudged: int = 0
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def verdict(self) -> str:
|
|
237
|
+
"""The three words, in the one order that cannot flatter the run.
|
|
238
|
+
|
|
239
|
+
An effect that was ever unproven is `ungoverned` whatever happened
|
|
240
|
+
afterwards: a verdict is about the whole run, and a later decision does
|
|
241
|
+
not retroactively decide an effect that already went unproven. A point
|
|
242
|
+
no event reached is `not-exercised`, which is an absence and is said as
|
|
243
|
+
one rather than counted with the sound ones (article 2).
|
|
244
|
+
|
|
245
|
+
`unjudged` is not in here and must not be: it is a count of what this
|
|
246
|
+
run could not establish, and every way of turning it into one of the
|
|
247
|
+
three words says something the run did not measure — `ungoverned` would
|
|
248
|
+
be a finding nobody made, `not-exercised` would deny the event that did
|
|
249
|
+
arrive, and `governed` would be the false all-clear. So the verdict
|
|
250
|
+
stays honest about the events that WERE judged, the count is published
|
|
251
|
+
beside it, and the exit code is what refuses to call the run a pass
|
|
252
|
+
(`verify._exit_for`).
|
|
253
|
+
"""
|
|
254
|
+
if self.refused:
|
|
255
|
+
return UNGOVERNED
|
|
256
|
+
return NOT_EXERCISED if self.events == 0 else GOVERNED
|
|
257
|
+
|
|
258
|
+
def to_document(self) -> dict[str, object]:
|
|
259
|
+
return {
|
|
260
|
+
"pack": self.pack,
|
|
261
|
+
"module": self.point.module,
|
|
262
|
+
"attribute": self.point.attribute,
|
|
263
|
+
"capability": self.point.capability,
|
|
264
|
+
"audit_event": self.point.audit_event,
|
|
265
|
+
"verdict": self.verdict,
|
|
266
|
+
"events": self.events,
|
|
267
|
+
UNJUDGED: self.unjudged,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class Watch:
|
|
272
|
+
"""Whose act an event was: the program's, or this harness's own.
|
|
273
|
+
|
|
274
|
+
The question exists because the proof runs inside the process it is
|
|
275
|
+
proving. Four rules, each excluding a different kind of act that is not the
|
|
276
|
+
program's, and each one measurable rather than argued.
|
|
277
|
+
|
|
278
|
+
**Armed only while the program runs.** Nothing is consulted before the
|
|
279
|
+
launcher hands over or after the program is DONE. Before: the engine's load
|
|
280
|
+
of a pack's execution module and the lookup of the target's own name.
|
|
281
|
+
After: the report write and the traceback the interpreter is about to
|
|
282
|
+
render. The hook is installed before the engine on purpose — an interpreter
|
|
283
|
+
that could be asked to forget a hook would make the proof optional — so the
|
|
284
|
+
arming, and not the installation, is what draws the line.
|
|
285
|
+
|
|
286
|
+
« Done » is not « its main module returned ». A non-daemon thread the
|
|
287
|
+
program started outlives that, and so does a handler it registered to run
|
|
288
|
+
at exit; both are the program's own code, and the interpreter runs both
|
|
289
|
+
after this process would otherwise have concluded.
|
|
290
|
+
`_wait_for_the_program` is what makes this paragraph true, and says what it
|
|
291
|
+
cost to leave untrue.
|
|
292
|
+
|
|
293
|
+
**Not before the program's own code begins.** Handing a program over means
|
|
294
|
+
locating it, reading it, reading or writing its bytecode cache, and only
|
|
295
|
+
then executing it; every one of those is the hand-off's act and the
|
|
296
|
+
interpreter reports them all. So nothing is judged until the interpreter
|
|
297
|
+
reports one of the program's OWN code objects — the launcher names every
|
|
298
|
+
file whose execution is the program running, and the FIRST of them to
|
|
299
|
+
execute opens the gate. For a package named to `-m` that is its
|
|
300
|
+
`__init__`, not its `__main__`: the `__init__` is the program's own code
|
|
301
|
+
and it runs first, and a gate that waited for the `__main__` left every
|
|
302
|
+
effect the `__init__` made unjudged and unaborted — measured as exit 0 over
|
|
303
|
+
a spawn no decision covered.
|
|
304
|
+
|
|
305
|
+
The gate is also what makes the count exact rather than nearly exact: the
|
|
306
|
+
import system opens a bytecode cache under a name it derives itself and
|
|
307
|
+
then writes through a bare descriptor, and neither can be recognised by any
|
|
308
|
+
path a launcher could have named in advance.
|
|
309
|
+
|
|
310
|
+
Its failure mode is the safe one, which is why it is allowed to be the rule
|
|
311
|
+
that matters. A target whose executed code object does not carry a file the
|
|
312
|
+
lookup resolved — a module shipped as bytecode with no source beside it —
|
|
313
|
+
leaves this gate shut, and a run whose gate never opened concludes nothing
|
|
314
|
+
at all: it says so and answers « could not read », rather than reporting
|
|
315
|
+
the absence it never established (`_prove` says where).
|
|
316
|
+
|
|
317
|
+
**Not inside a consultation.** A consultation reads the chain, and those
|
|
318
|
+
reads are themselves acts the interpreter reports. Per thread, so an event
|
|
319
|
+
arriving on another thread is still the program's and is still judged.
|
|
320
|
+
|
|
321
|
+
**Not the files the hand-off itself reads, while it is still reading
|
|
322
|
+
them.** The program's own file, a package's `__main__`, and the bytecode
|
|
323
|
+
cache the import system keeps for each — named by the launcher, which asks
|
|
324
|
+
the import system where a cache lives rather than spelling it. The import
|
|
325
|
+
system's derivation from a cache is the one clause that outlives the gate
|
|
326
|
+
opening: it writes a cache by creating a file named after that cache plus a
|
|
327
|
+
number of its own, and a number known to nobody in advance can only be
|
|
328
|
+
matched by the derivation, whenever it fires.
|
|
329
|
+
|
|
330
|
+
**And whose act it was is three answers, not two.** The trade this rule
|
|
331
|
+
used to make was stated here, and stated too weakly: « what it excludes and
|
|
332
|
+
should not is a program that opens one of its OWN start files or their
|
|
333
|
+
caches; that shows up as an absence — `not-exercised` — and never as a
|
|
334
|
+
pass, which is the direction this trade has to fail in. » That held only
|
|
335
|
+
when EVERY event on the point was excluded. Mixed with one judged event, the
|
|
336
|
+
excluded ones vanished behind it and the run answered `governed`, exit 0 —
|
|
337
|
+
measured through the shipped command on two pairs of programs differing in
|
|
338
|
+
one argument — one pair opening a store under the program's own path
|
|
339
|
+
instead of an ordinary one, the other spawning the program's own file as
|
|
340
|
+
the executable instead of an ordinary command — each second effect real,
|
|
341
|
+
and covered by no decision. That is the false all-clear article 2 forbids,
|
|
342
|
+
in the component whose whole value is catching it.
|
|
343
|
+
|
|
344
|
+
So an argument that names one of the program's own start files or their
|
|
345
|
+
caches, arriving AFTER the gate opened, is answered `NOT_JUDGED` rather
|
|
346
|
+
than `THE_HAND_OFFS`: the hand-off has finished reading those files by
|
|
347
|
+
then, and this proof cannot tell the program naming its own file from the
|
|
348
|
+
import system finishing with it. The caller counts it on the point and the
|
|
349
|
+
run cannot be a pass. Measured on the six ordinary forms — a script, a
|
|
350
|
+
module and a package, each cold and warm — every exclusion of either clause
|
|
351
|
+
fires before the gate opens, so the count is zero in all of them and this
|
|
352
|
+
answer is reserved for the shape it was written for.
|
|
353
|
+
|
|
354
|
+
This is the ONE place an event's arguments are read, and reading them
|
|
355
|
+
decides only whose act it was. Which arguments identify an effect is the
|
|
356
|
+
pack's declaration and the boundary's digest (article 11); a verifier with
|
|
357
|
+
an opinion of its own about them would be a second policy.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
def __init__(self) -> None:
|
|
361
|
+
self.armed = False
|
|
362
|
+
self.started = False
|
|
363
|
+
self.excluded: frozenset[str] = frozenset()
|
|
364
|
+
#: The problem of a consultation in which no read was answered. Carried
|
|
365
|
+
#: here as well as raised, so a target that caught the exception cannot
|
|
366
|
+
#: bury the fact that nothing was verified.
|
|
367
|
+
self.unreadable: Problem | None = None
|
|
368
|
+
#: Whether the gate EVER opened. `disarm` clears `started`, and the run
|
|
369
|
+
#: has to be able to tell « this program walked no such path » from
|
|
370
|
+
#: « this proof never began watching » afterwards.
|
|
371
|
+
self.ever_started = False
|
|
372
|
+
self._starts: frozenset[str] = frozenset()
|
|
373
|
+
self._derived: frozenset[str] = frozenset()
|
|
374
|
+
self._ours = threading.local()
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def judging(self) -> bool:
|
|
378
|
+
"""Whether an event now is one this run may conclude anything from."""
|
|
379
|
+
return self.armed and self.started
|
|
380
|
+
|
|
381
|
+
def arm(self, starts: tuple[str, ...], own: tuple[str, ...], caches: tuple[str, ...]) -> None:
|
|
382
|
+
"""The launcher is about to hand over.
|
|
383
|
+
|
|
384
|
+
`starts` are the program's OWN files — every one whose execution is the
|
|
385
|
+
program running rather than the hand-off preparing it; the first of them
|
|
386
|
+
to execute opens the gate. `own` are the files the hand-off reads to get
|
|
387
|
+
there, and `caches` are those of them the import system derives further
|
|
388
|
+
names from. All three come from the launcher, which is the only place
|
|
389
|
+
they are known: resolving them again here, on a different import path,
|
|
390
|
+
would be a different question with a different answer.
|
|
391
|
+
"""
|
|
392
|
+
self._starts = _the_paths(starts)
|
|
393
|
+
self.excluded = _the_paths(own)
|
|
394
|
+
self._derived = _the_paths(caches)
|
|
395
|
+
self.started = False
|
|
396
|
+
self.armed = True
|
|
397
|
+
|
|
398
|
+
def opening(self, arguments: tuple[object, ...]) -> None:
|
|
399
|
+
"""Open the gate once the interpreter reports the program's code, and alone.
|
|
400
|
+
|
|
401
|
+
The interpreter reports running a code object by handing a hook that
|
|
402
|
+
code object and nothing besides. Other events carry one too, and one of
|
|
403
|
+
them comes first: the import system marshals a freshly compiled module
|
|
404
|
+
in order to write its bytecode cache. So « the code object, by itself »
|
|
405
|
+
is what tells « this is being run » from « this is being written down ».
|
|
406
|
+
Measured on the way to this line: keyed on the code object wherever it
|
|
407
|
+
appeared, the gate opened on the cache write and two of the hand-off's
|
|
408
|
+
own file events were judged as the program's.
|
|
409
|
+
|
|
410
|
+
It is deliberately NOT keyed on the event's name. Every audit event
|
|
411
|
+
this harness knows is read from a pack's manifest, and the interpreter's
|
|
412
|
+
own name for running code is read from nobody's — a name spelled here
|
|
413
|
+
would be this file holding a vocabulary of its own, which is the thing
|
|
414
|
+
article 4 denies it. The shape is an interpreter detail, so it is
|
|
415
|
+
measured rather than trusted: `tests/test_instrument_verify.py` counts
|
|
416
|
+
the events a `-m` run produces, cold and warm, and a shape that stops
|
|
417
|
+
being unique arrives there as a red test.
|
|
418
|
+
"""
|
|
419
|
+
if not self.armed or self.started or not self._starts:
|
|
420
|
+
return
|
|
421
|
+
if len(arguments) == 1 and _the_code_of(arguments[0]) in self._starts:
|
|
422
|
+
self.started = True
|
|
423
|
+
self.ever_started = True
|
|
424
|
+
|
|
425
|
+
def disarm(self) -> None:
|
|
426
|
+
"""The program is done. Nothing after this is the program's act."""
|
|
427
|
+
self.armed = False
|
|
428
|
+
self.started = False
|
|
429
|
+
|
|
430
|
+
@contextmanager
|
|
431
|
+
def ours(self) -> Iterator[None]:
|
|
432
|
+
"""This harness's own work, for the length of the block."""
|
|
433
|
+
self._ours.busy = True
|
|
434
|
+
try:
|
|
435
|
+
yield
|
|
436
|
+
finally:
|
|
437
|
+
self._ours.busy = False
|
|
438
|
+
|
|
439
|
+
def whose(self, arguments: tuple[object, ...]) -> str:
|
|
440
|
+
"""Whose act this event was: the program's, this process's own, or nobody's.
|
|
441
|
+
|
|
442
|
+
The third answer is the one that cannot be folded into the second. Read
|
|
443
|
+
in the order below, because the clauses are not alternatives: an
|
|
444
|
+
argument the import system derived is the import system's whenever it
|
|
445
|
+
appears, and only what is left over can be the program naming its own
|
|
446
|
+
start file.
|
|
447
|
+
"""
|
|
448
|
+
if not self.judging or getattr(self._ours, "busy", False):
|
|
449
|
+
return THE_HAND_OFFS
|
|
450
|
+
named = [name for name in map(_resolved_argument, arguments) if name is not None]
|
|
451
|
+
if any(self._derived_from_a_cache(name) for name in named):
|
|
452
|
+
# A cache the import system derives is never the program's act, so
|
|
453
|
+
# it is not judged and not aborted; but the gate is open here, and
|
|
454
|
+
# a write this proof did not judge is COUNTED, so the run can never
|
|
455
|
+
# read as a pass over it (article 2). Every ordinary form writes its
|
|
456
|
+
# caches before the gate opens, so this count is zero where it is
|
|
457
|
+
# green today; a program naming its own cache path is the one case.
|
|
458
|
+
return NOT_JUDGED
|
|
459
|
+
if any(name in self.excluded for name in named):
|
|
460
|
+
return NOT_JUDGED
|
|
461
|
+
return THE_PROGRAMS
|
|
462
|
+
|
|
463
|
+
def _derived_from_a_cache(self, named: str) -> bool:
|
|
464
|
+
"""Whether a name is one the import system derived from a cache it was given.
|
|
465
|
+
|
|
466
|
+
It writes a bytecode cache by creating a file named after that cache
|
|
467
|
+
plus a number of its own, and renaming it. The number is the import
|
|
468
|
+
system's and is never known in advance, so only the derivation can
|
|
469
|
+
match it — which is why the caches arrive separately from the rest, and
|
|
470
|
+
why this is the clause that is true whenever it fires rather than only
|
|
471
|
+
while the hand-off is still reading.
|
|
472
|
+
"""
|
|
473
|
+
return any(named.startswith(f"{cache}.") for cache in self._derived)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _the_paths(paths: tuple[str, ...]) -> frozenset[str]:
|
|
477
|
+
"""Those of these this process can resolve, as it will see them reported."""
|
|
478
|
+
return frozenset(
|
|
479
|
+
resolved for resolved in (_resolved(path) for path in paths) if resolved is not None
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _resolved(path: str) -> str | None:
|
|
484
|
+
"""One path as this process will see it reported, or `None` if unusable."""
|
|
485
|
+
try:
|
|
486
|
+
return str(Path(path).resolve())
|
|
487
|
+
except (OSError, ValueError, RuntimeError):
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _the_code_of(argument: object) -> str | None:
|
|
492
|
+
"""The file an argument was compiled from, if the argument is a code object.
|
|
493
|
+
|
|
494
|
+
Read as its own question rather than through the path reader below, because
|
|
495
|
+
the gate asks something narrower: not « does this event mention a file »
|
|
496
|
+
but « is this event the interpreter executing the program's own code ». No
|
|
497
|
+
event carries a code object for the program's file before that.
|
|
498
|
+
"""
|
|
499
|
+
compiled = getattr(argument, "co_filename", None)
|
|
500
|
+
return _resolved(compiled) if isinstance(compiled, str) else None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _resolved_argument(argument: object) -> str | None:
|
|
504
|
+
"""The file an event's argument names, if it names one.
|
|
505
|
+
|
|
506
|
+
An argument may be a path, the bytes of one, something that supplies one,
|
|
507
|
+
or a code object carrying the file it was compiled from. Anything else —
|
|
508
|
+
a mode, a flag, an open descriptor — names no file and is not one.
|
|
509
|
+
"""
|
|
510
|
+
if isinstance(argument, str):
|
|
511
|
+
named: str | None = argument
|
|
512
|
+
elif isinstance(argument, bytes):
|
|
513
|
+
try:
|
|
514
|
+
named = argument.decode()
|
|
515
|
+
except UnicodeDecodeError:
|
|
516
|
+
return None
|
|
517
|
+
elif isinstance(argument, os.PathLike):
|
|
518
|
+
named = os.fspath(argument) if isinstance(os.fspath(argument), str) else None
|
|
519
|
+
else:
|
|
520
|
+
compiled = getattr(argument, "co_filename", None)
|
|
521
|
+
named = compiled if isinstance(compiled, str) else None
|
|
522
|
+
return None if named is None else _resolved(named)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@dataclass(frozen=True)
|
|
526
|
+
class Configuration:
|
|
527
|
+
"""What the command asked this harness to prove, read strictly."""
|
|
528
|
+
|
|
529
|
+
packs: tuple[Path, ...]
|
|
530
|
+
profile: SocketProfile
|
|
531
|
+
principal: str | None
|
|
532
|
+
governed: bool
|
|
533
|
+
report: Path
|
|
534
|
+
#: Where this harness says which of its own endings happened. Validated like
|
|
535
|
+
#: `report`, because a run that said it nowhere would leave the command
|
|
536
|
+
#: reading the target's exit status again (`_write_outcome` says what that
|
|
537
|
+
#: cost).
|
|
538
|
+
outcome: Path
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@dataclass(frozen=True)
|
|
542
|
+
class Consultation:
|
|
543
|
+
"""What one consultation of the chain established, and what it could not.
|
|
544
|
+
|
|
545
|
+
Three outcomes rather than two, which is article 2's rule about a status
|
|
546
|
+
surface applied to a single read: a record was found, no record was found
|
|
547
|
+
in a chain that WAS read, or the chain was not read at all. Only the second
|
|
548
|
+
is a finding about the program.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
matched: int | None
|
|
552
|
+
read_something: bool
|
|
553
|
+
problem: Problem | None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
@dataclass
|
|
557
|
+
class Chain:
|
|
558
|
+
"""The scope's evidence, as far as this harness has consumed it.
|
|
559
|
+
|
|
560
|
+
The cursor is the sequence of the last entry a consultation matched. A read
|
|
561
|
+
starts after it, so one recorded effect cannot answer for two events: the
|
|
562
|
+
second consultation never sees it again.
|
|
563
|
+
"""
|
|
564
|
+
|
|
565
|
+
connection: VerifiedConnection
|
|
566
|
+
scope: str
|
|
567
|
+
cursor: int
|
|
568
|
+
#: One consultation at a time, because an audit event can arrive on any
|
|
569
|
+
#: thread and two of them sharing one HTTP connection would interleave two
|
|
570
|
+
#: reads on one socket.
|
|
571
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _write_outcome(
|
|
575
|
+
path: Path, outcome: str, detail: str, *, problem: Problem | None = None
|
|
576
|
+
) -> None:
|
|
577
|
+
"""Say which of this harness's own endings happened, where the target cannot.
|
|
578
|
+
|
|
579
|
+
The command that spawned this process used to read the PROGRAM's exit
|
|
580
|
+
status to tell « this invocation was refused » from « this run concluded
|
|
581
|
+
nothing », and a program that calls `os._exit(64)` chose that reading for
|
|
582
|
+
it. An exit status carries one number and there are two facts; this file
|
|
583
|
+
carries the second.
|
|
584
|
+
|
|
585
|
+
The channel is honest against the ordinary program rather than against a
|
|
586
|
+
hostile one: a target running as this user could write or unlink the file
|
|
587
|
+
itself if it went looking for it. What it cannot do is choose the reading by
|
|
588
|
+
accident, which is the whole of the collision this closes — and a target
|
|
589
|
+
that wrote a false outcome deliberately would be a target lying about a
|
|
590
|
+
proof it also controls the events of.
|
|
591
|
+
|
|
592
|
+
**The transport's own classification travels with it where there is one.**
|
|
593
|
+
Two of these endings are a chain read that did not answer, and the read
|
|
594
|
+
carried a `Problem` the contract classified. Writing its code here is what
|
|
595
|
+
stops the command that reads this file from minting a classification of its
|
|
596
|
+
own for a problem the control plane already named — which is the same rule
|
|
597
|
+
as « never derive an answer the control plane did not give » (article 1),
|
|
598
|
+
applied to a problem rather than to a decision.
|
|
599
|
+
|
|
600
|
+
Written best-effort: a run that could not write it is a run the caller
|
|
601
|
+
reports as one that concluded nothing, which is what an absent file already
|
|
602
|
+
means. Failing here would replace a readable non-answer with a traceback.
|
|
603
|
+
The vocabulary check is not best-effort and is not an `assert`: assertions
|
|
604
|
+
are stripped under `-O`, which this process inherits from its environment,
|
|
605
|
+
and a word outside `OUTCOMES` written there would be read back as nothing
|
|
606
|
+
at all.
|
|
607
|
+
"""
|
|
608
|
+
if outcome not in OUTCOMES:
|
|
609
|
+
raise ValueError(f"{outcome!r} is not one of this harness's own endings: {OUTCOMES}")
|
|
610
|
+
said: dict[str, object] = {"outcome": outcome, "detail": detail}
|
|
611
|
+
if problem is not None:
|
|
612
|
+
code = problem.code
|
|
613
|
+
said["problem_code"] = code.value if isinstance(code, ProblemCode) else code.raw
|
|
614
|
+
with suppress(OSError):
|
|
615
|
+
path.write_text(json.dumps(said) + "\n", encoding="utf-8")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
619
|
+
"""Prove one program, write the report, and leave the verdict to the caller.
|
|
620
|
+
|
|
621
|
+
The exit code here is about this harness: zero once the report is written,
|
|
622
|
+
whatever the report says. The command that spawned it reads the report and
|
|
623
|
+
the outcome file, never this number and never the program's — because « the
|
|
624
|
+
program ended », « the program was governed » and « this harness got as far
|
|
625
|
+
as X » are three different facts and one number cannot carry them.
|
|
626
|
+
"""
|
|
627
|
+
forwarded = list(sys.argv[1:] if argv is None else argv)
|
|
628
|
+
try:
|
|
629
|
+
configured, target = _asked(forwarded)
|
|
630
|
+
except HarnessMisuse as misuse:
|
|
631
|
+
# There is no configuration, so there is no path to say this on. The
|
|
632
|
+
# caller reads the absence of the file as « the harness wrote nothing at
|
|
633
|
+
# all », which is exactly what happened.
|
|
634
|
+
sys.stderr.write(f"{misuse}\n")
|
|
635
|
+
return exit_codes.EXIT_MISUSE
|
|
636
|
+
try:
|
|
637
|
+
packs = [manifest.read_pack(path) for path in configured.packs]
|
|
638
|
+
except manifest.PackInvalid as misuse:
|
|
639
|
+
_write_outcome(configured.outcome, INVOCATION_REFUSED, str(misuse))
|
|
640
|
+
sys.stderr.write(f"{misuse}\n")
|
|
641
|
+
return exit_codes.EXIT_MISUSE
|
|
642
|
+
try:
|
|
643
|
+
connection = connect(configured.profile)
|
|
644
|
+
except SocketClientProblem as failure:
|
|
645
|
+
# No question was ever put and no chain was ever read, so there is no
|
|
646
|
+
# report to write: nothing was proven and nothing is claimed.
|
|
647
|
+
_write_outcome(
|
|
648
|
+
configured.outcome,
|
|
649
|
+
CHAIN_UNREADABLE_BEFORE,
|
|
650
|
+
failure.problem.message,
|
|
651
|
+
problem=failure.problem,
|
|
652
|
+
)
|
|
653
|
+
sys.stderr.write(
|
|
654
|
+
f"the chain could not be read, so nothing was verified: {failure.problem.message}\n"
|
|
655
|
+
)
|
|
656
|
+
return exit_codes.EXIT_COULD_NOT_ASK
|
|
657
|
+
try:
|
|
658
|
+
return _prove(configured, packs, target, connection)
|
|
659
|
+
finally:
|
|
660
|
+
connection.close()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _prove(
|
|
664
|
+
configured: Configuration,
|
|
665
|
+
packs: Sequence[manifest.Pack],
|
|
666
|
+
target: Sequence[str],
|
|
667
|
+
connection: VerifiedConnection,
|
|
668
|
+
) -> int:
|
|
669
|
+
"""Read the chain's head, install the hook, hand the program over, report."""
|
|
670
|
+
watched = [Watched(pack.name, point) for pack in packs for point in pack.points]
|
|
671
|
+
by_event: dict[str, list[Watched]] = {}
|
|
672
|
+
for item in watched:
|
|
673
|
+
by_event.setdefault(item.point.audit_event, []).append(item)
|
|
674
|
+
watch = Watch()
|
|
675
|
+
chain = Chain(connection, configured.profile.scope, cursor=0)
|
|
676
|
+
with watch.ours():
|
|
677
|
+
head = _head_of_the_chain(chain)
|
|
678
|
+
if isinstance(head, Problem):
|
|
679
|
+
_write_outcome(configured.outcome, CHAIN_UNREADABLE_BEFORE, head.message, problem=head)
|
|
680
|
+
sys.stderr.write(f"the chain could not be read, so nothing was verified: {head.message}\n")
|
|
681
|
+
return exit_codes.EXIT_COULD_NOT_ASK
|
|
682
|
+
chain.cursor = head - 1
|
|
683
|
+
# Installed before the hand-off — and before the engine, so an interpreter
|
|
684
|
+
# that could be asked to forget a hook cannot make the proof optional. What
|
|
685
|
+
# draws the line between this harness's work and the program's is the
|
|
686
|
+
# ARMING, which the launcher does at the last instant (`Watch` says why).
|
|
687
|
+
sys.addaudithook(_consulting(chain, by_event, watch))
|
|
688
|
+
ending: int | None = None
|
|
689
|
+
refused_the_invocation = False
|
|
690
|
+
try:
|
|
691
|
+
ending = _run_the_target(configured, packs, target, watch)
|
|
692
|
+
except HarnessMisuse as misuse:
|
|
693
|
+
# Before the hand-off every failure is this invocation's and the program
|
|
694
|
+
# has not started (`launch.py` draws that line): there is nothing to
|
|
695
|
+
# report about a program that never ran, and findings written anyway
|
|
696
|
+
# would say `not-exercised` about a path no program was there to walk.
|
|
697
|
+
refused_the_invocation = True
|
|
698
|
+
_write_outcome(configured.outcome, INVOCATION_REFUSED, str(misuse))
|
|
699
|
+
sys.stderr.write(f"{misuse}\n")
|
|
700
|
+
return exit_codes.EXIT_MISUSE
|
|
701
|
+
except engine.EngineMisuse as misuse:
|
|
702
|
+
# A point naming an attribute a module does not have, discovered inside
|
|
703
|
+
# the program's own import — the one check that cannot be made before
|
|
704
|
+
# the hand-off (`engine.py` says why). The pack is the invocation's and
|
|
705
|
+
# the program is innocent: no question was ever put, so this is the same
|
|
706
|
+
# refusal `instrument run` gives the same mistake, and the command that
|
|
707
|
+
# spawned this reads it off the outcome rather than off a number.
|
|
708
|
+
refused_the_invocation = True
|
|
709
|
+
_write_outcome(configured.outcome, INVOCATION_REFUSED, str(misuse))
|
|
710
|
+
sys.stderr.write(f"{misuse}\n")
|
|
711
|
+
return exit_codes.EXIT_MISUSE
|
|
712
|
+
except ChainUnreadable:
|
|
713
|
+
# The hook aborted an effect it could not verify. It is answered after
|
|
714
|
+
# the `finally`, from the watch rather than from here, so that a target
|
|
715
|
+
# which caught the exception cannot bury it.
|
|
716
|
+
pass
|
|
717
|
+
finally:
|
|
718
|
+
# The program is not done because its main module returned, so the
|
|
719
|
+
# watch stays armed until it is — its own threads run out and its own
|
|
720
|
+
# exit handlers run, under the watch. Only then does consultation stop,
|
|
721
|
+
# and it stops before anything else this process does: the report write
|
|
722
|
+
# and the traceback the interpreter is about to render.
|
|
723
|
+
_wait_for_the_program(chain)
|
|
724
|
+
watch.disarm()
|
|
725
|
+
if not refused_the_invocation and watch.unreadable is None and watch.ever_started:
|
|
726
|
+
with watch.ours():
|
|
727
|
+
_write_report(configured, packs, watched, head, ending, connection)
|
|
728
|
+
if watch.unreadable is not None:
|
|
729
|
+
_write_outcome(
|
|
730
|
+
configured.outcome,
|
|
731
|
+
CHAIN_UNREADABLE_DURING,
|
|
732
|
+
watch.unreadable.message,
|
|
733
|
+
problem=watch.unreadable,
|
|
734
|
+
)
|
|
735
|
+
sys.stderr.write(
|
|
736
|
+
f"the chain could not be read while the program ran, so nothing was verified: "
|
|
737
|
+
f"{watch.unreadable.message}\n"
|
|
738
|
+
)
|
|
739
|
+
return exit_codes.EXIT_COULD_NOT_ASK
|
|
740
|
+
if not watch.ever_started:
|
|
741
|
+
# The one fact that separates « this program walked no such path » from
|
|
742
|
+
# « this proof never began watching ». Without it both rendered as
|
|
743
|
+
# `not-exercised`, byte for byte — so a run that watched nothing at all
|
|
744
|
+
# read exactly like a run that established an absence, which is the
|
|
745
|
+
# distinction the rest of this file is fastidious about (article 2).
|
|
746
|
+
_write_outcome(configured.outcome, GATE_NEVER_OPENED, " ".join(target))
|
|
747
|
+
sys.stderr.write(f"{NEVER_STARTED}\n")
|
|
748
|
+
return exit_codes.EXIT_COULD_NOT_ASK
|
|
749
|
+
# No detail: the only path that reads this one is a report that will not
|
|
750
|
+
# parse, and the report lives in a directory the command removes before the
|
|
751
|
+
# reader ever sees the sentence — a path nobody can open is worse than none.
|
|
752
|
+
_write_outcome(configured.outcome, REPORTED, "")
|
|
753
|
+
return exit_codes.EXIT_ALLOW
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _wait_for_the_program(chain: Chain) -> None:
|
|
757
|
+
"""Run out everything the program left to do, before anything is concluded.
|
|
758
|
+
|
|
759
|
+
A main module returning is not a program ending. Two things outlive it and
|
|
760
|
+
both are the program's own code: a non-daemon thread it started, and a
|
|
761
|
+
handler it registered to run at exit. The interpreter runs both — threads
|
|
762
|
+
first, then the exit handlers — after this process would otherwise have
|
|
763
|
+
disarmed the watch and written its findings. Measured before this existed,
|
|
764
|
+
each against a chain holding one record and a program that spawned once
|
|
765
|
+
itself: a thread that spawned 0.6 s later, and a handler that spawned at
|
|
766
|
+
exit, each answered **exit 0 and `governed`** while BOTH processes ran —
|
|
767
|
+
the second with no decision behind it, unjudged, unaborted and unreported.
|
|
768
|
+
That is the false all-clear article 2 forbids, and a worker thread is the
|
|
769
|
+
ordinary shape of the runtimes this chain exists to instrument.
|
|
770
|
+
|
|
771
|
+
So both are run here, in the interpreter's own order, while the watch is
|
|
772
|
+
still armed. **The exit handlers are run by this function rather than left
|
|
773
|
+
to the interpreter**, and the choice is not about ordering among handlers —
|
|
774
|
+
registering this harness's own work first would indeed run it last. It is
|
|
775
|
+
about what a handler needs while it runs: the chain connection, which
|
|
776
|
+
`main` closes on its way out, and a thread the handler may itself start,
|
|
777
|
+
which nothing after the interpreter's handlers would join. Leaving the
|
|
778
|
+
normal path to shutdown would put a consultation after the connection it
|
|
779
|
+
reads through was closed. Doing it here keeps the whole sequence in one
|
|
780
|
+
place a reader can see, and `atexit` clears its own register as it runs, so
|
|
781
|
+
the interpreter's later call finds nothing and no handler runs twice.
|
|
782
|
+
|
|
783
|
+
A handler's own failure is the program's, and `atexit` reports it the way
|
|
784
|
+
the interpreter would and carries on — including the abort this harness
|
|
785
|
+
raises into an ungoverned one, which is why a handler that is stopped does
|
|
786
|
+
not stop the report.
|
|
787
|
+
|
|
788
|
+
Every other handler in the register runs too, a few levels down in the
|
|
789
|
+
standard library's own imports among them. That is not a liberty: the
|
|
790
|
+
interpreter would run every one of them moments later, and nothing this
|
|
791
|
+
process has left to do — writing a file, closing a socket — is anything
|
|
792
|
+
they could take away.
|
|
793
|
+
|
|
794
|
+
Daemon threads are not joined, because the interpreter does not join them
|
|
795
|
+
either — it ends them at shutdown — so waiting for one could wait for ever
|
|
796
|
+
on a thread the program never meant to finish. Waiting is therefore exactly
|
|
797
|
+
as long as the interpreter itself would wait, and no longer; a program that
|
|
798
|
+
hangs a non-daemon thread hangs this run, which is bounded by the command's
|
|
799
|
+
own timeout rather than pretended away here.
|
|
800
|
+
|
|
801
|
+
Threads are enumerated in a loop, and joined again after the handlers,
|
|
802
|
+
because each of those can start another.
|
|
803
|
+
|
|
804
|
+
Then the lock, which a consultation holds while it polls: findings written
|
|
805
|
+
out from under a consultation still in flight would be findings made
|
|
806
|
+
without the answer they were waiting for.
|
|
807
|
+
"""
|
|
808
|
+
_join_the_programs_threads()
|
|
809
|
+
# Not guarded against an exception: `atexit` prints what a handler raised
|
|
810
|
+
# and goes on to the next, exactly as the interpreter does, so there is
|
|
811
|
+
# nothing here to catch that it has not already reported.
|
|
812
|
+
atexit._run_exitfuncs()
|
|
813
|
+
_join_the_programs_threads()
|
|
814
|
+
with chain.lock:
|
|
815
|
+
pass
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _join_the_programs_threads() -> None:
|
|
819
|
+
"""Join every thread the interpreter itself would wait for, and no other."""
|
|
820
|
+
while True:
|
|
821
|
+
left = [
|
|
822
|
+
thread
|
|
823
|
+
for thread in threading.enumerate()
|
|
824
|
+
if thread is not threading.current_thread() and not thread.daemon and thread.is_alive()
|
|
825
|
+
]
|
|
826
|
+
if not left:
|
|
827
|
+
break
|
|
828
|
+
for thread in left:
|
|
829
|
+
thread.join()
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _run_the_target(
|
|
833
|
+
configured: Configuration,
|
|
834
|
+
packs: Sequence[manifest.Pack],
|
|
835
|
+
target: Sequence[str],
|
|
836
|
+
watch: Watch,
|
|
837
|
+
) -> int | None:
|
|
838
|
+
"""Hand the program over, in the mode the invocation asked for.
|
|
839
|
+
|
|
840
|
+
With `governed`, exactly what the launcher does, boundary and engine
|
|
841
|
+
included: the verifier then measures the shipped mode. Without it, the same
|
|
842
|
+
hand-off with nothing in front of the program, which is how the chain alone
|
|
843
|
+
is asked whether an effect was decided.
|
|
844
|
+
|
|
845
|
+
Either way the launcher arms the watch at the last instant before the
|
|
846
|
+
program starts, and tells it which files the interpreter reads to start it.
|
|
847
|
+
|
|
848
|
+
`LaunchMisuse` is the invocation's mistake and not a verdict, so it ends
|
|
849
|
+
this harness with no report rather than with an unproven `governed`.
|
|
850
|
+
"""
|
|
851
|
+
try:
|
|
852
|
+
if configured.governed:
|
|
853
|
+
return launch.run(
|
|
854
|
+
list(packs),
|
|
855
|
+
configured.profile,
|
|
856
|
+
list(target),
|
|
857
|
+
principal=configured.principal,
|
|
858
|
+
out=sys.stdout,
|
|
859
|
+
err=sys.stderr,
|
|
860
|
+
starting=watch.arm,
|
|
861
|
+
)
|
|
862
|
+
return launch.hand_over(list(target), err=sys.stderr, starting=watch.arm)
|
|
863
|
+
except launch.LaunchMisuse as misuse:
|
|
864
|
+
raise HarnessMisuse(str(misuse)) from misuse
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _head_of_the_chain(chain: Chain) -> int | Problem:
|
|
868
|
+
"""The sequence a record written by this run would take, or the problem that it is unknown.
|
|
869
|
+
|
|
870
|
+
The chain is walked to its end once, before the program starts, so that a
|
|
871
|
+
record already there cannot be mistaken for one this run produced. An empty
|
|
872
|
+
chain answers 1, which is the sequence the first entry would be given.
|
|
873
|
+
|
|
874
|
+
The `Problem` and not only its message, because the caller writes its code
|
|
875
|
+
into the outcome file: a read the daemon answered in a generation this
|
|
876
|
+
client does not speak was classified by the transport, and a command that
|
|
877
|
+
was handed that classification and published another of its own would be
|
|
878
|
+
deriving an answer nobody gave it (article 1).
|
|
879
|
+
"""
|
|
880
|
+
from_sequence = 1
|
|
881
|
+
highest = 0
|
|
882
|
+
while True:
|
|
883
|
+
# Bound as a default so the read is of THIS page and not of whatever
|
|
884
|
+
# the walk moved on to: the callable outlives one turn of the loop.
|
|
885
|
+
result = reads.read(lambda at=from_sequence: _one_page(chain, at))
|
|
886
|
+
if not isinstance(result, Answered):
|
|
887
|
+
return result.problem
|
|
888
|
+
entries, _, next_from = result.value
|
|
889
|
+
for entry in entries:
|
|
890
|
+
sequence = entry["sequence"]
|
|
891
|
+
if isinstance(sequence, int) and sequence > highest:
|
|
892
|
+
highest = sequence
|
|
893
|
+
if next_from is None:
|
|
894
|
+
return highest + 1
|
|
895
|
+
from_sequence = next_from
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _one_page(
|
|
899
|
+
chain: Chain, from_sequence: int
|
|
900
|
+
) -> Result[tuple[list[Mapping[str, object]], Mapping[str, object], int | None]]:
|
|
901
|
+
"""Re-open the connection, verify the far end again, and read one page.
|
|
902
|
+
|
|
903
|
+
Rule C4: the transport re-opens no address on a caller's behalf, and the
|
|
904
|
+
daemon closes the connection after some answers. Re-verifying before every
|
|
905
|
+
read keeps that explicit — it is cheap over a Unix socket — and it puts the
|
|
906
|
+
re-open inside the reader, so a socket that went away mid-run is a
|
|
907
|
+
transport problem this client classifies rather than an exception raised
|
|
908
|
+
out of an audit hook.
|
|
909
|
+
"""
|
|
910
|
+
chain.connection.reconnect()
|
|
911
|
+
result = chain.connection.read_evidence(chain.scope, from_sequence, PAGE_SIZE)
|
|
912
|
+
if not isinstance(result, Answered):
|
|
913
|
+
return result
|
|
914
|
+
return Answered(pages.members(result.value, from_sequence), result.contract_generation)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _consulting(
|
|
918
|
+
chain: Chain, by_event: Mapping[str, list[Watched]], watch: Watch
|
|
919
|
+
) -> Callable[[str, tuple[object, ...]], None]:
|
|
920
|
+
"""The audit hook: for an event a pack named, ask the chain and act on the answer.
|
|
921
|
+
|
|
922
|
+
Every other event returns on a dictionary lookup, which is what a hook
|
|
923
|
+
installed for the length of somebody's program has to cost.
|
|
924
|
+
|
|
925
|
+
A point the chain cannot account for raises, and the raise is the whole
|
|
926
|
+
mechanism: it aborts the effect inside the target rather than reporting
|
|
927
|
+
afterwards that an ungoverned effect had already happened. A point whose
|
|
928
|
+
chain could not be read raises too, and for the same reason — but with a
|
|
929
|
+
different exception and no finding, because that is not the same fact.
|
|
930
|
+
|
|
931
|
+
Whose act an event was is `Watch`'s question, asked first and answered
|
|
932
|
+
there; this function judges only what is left — and COUNTS what is neither
|
|
933
|
+
judged nor the hand-off's, which is the one thing it may not drop
|
|
934
|
+
(`Watch.whose` says what that shape is and what dropping it cost).
|
|
935
|
+
"""
|
|
936
|
+
|
|
937
|
+
def consult(event: str, arguments: tuple[object, ...]) -> None:
|
|
938
|
+
if not watch.judging:
|
|
939
|
+
# Either nothing has been handed over yet, or the hand-off is still
|
|
940
|
+
# locating and reading the program. Both are the watch's question
|
|
941
|
+
# and not this function's; the second is also where the gate opens.
|
|
942
|
+
watch.opening(arguments)
|
|
943
|
+
return
|
|
944
|
+
points = by_event.get(event)
|
|
945
|
+
if points is None:
|
|
946
|
+
return
|
|
947
|
+
whose = watch.whose(arguments)
|
|
948
|
+
if whose == THE_HAND_OFFS:
|
|
949
|
+
return
|
|
950
|
+
if whose == NOT_JUDGED:
|
|
951
|
+
# No chain read and no raise: nothing was established about this
|
|
952
|
+
# event, and inventing either answer would be a verdict nobody
|
|
953
|
+
# measured. It is counted on every point the event would have been
|
|
954
|
+
# judged against, which is what makes the run not a pass.
|
|
955
|
+
for item in points:
|
|
956
|
+
item.unjudged += 1
|
|
957
|
+
return
|
|
958
|
+
with watch.ours():
|
|
959
|
+
for item in points:
|
|
960
|
+
_judge(chain, item, watch)
|
|
961
|
+
|
|
962
|
+
return consult
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _judge(chain: Chain, item: Watched, watch: Watch) -> None:
|
|
966
|
+
"""One point, against one consultation of the chain."""
|
|
967
|
+
consultation = _consulted(chain, item.point.capability)
|
|
968
|
+
if consultation.matched is not None:
|
|
969
|
+
item.events += 1
|
|
970
|
+
return
|
|
971
|
+
if not consultation.read_something:
|
|
972
|
+
problem = consultation.problem
|
|
973
|
+
watch.unreadable = problem
|
|
974
|
+
raise ChainUnreadable(
|
|
975
|
+
f"the chain could not be read, so this effect is not verified: "
|
|
976
|
+
f"{item.point.capability} ({item.point.audit_event}): "
|
|
977
|
+
f"{'no answer at all' if problem is None else problem.message}"
|
|
978
|
+
)
|
|
979
|
+
if consultation.problem is not None:
|
|
980
|
+
# Some reads were answered and the last was not. The verdict below is
|
|
981
|
+
# made of what WAS read, and the failure is said beside it rather than
|
|
982
|
+
# folded into it: a reader has to be able to see that the chain went
|
|
983
|
+
# away while this run was concluding.
|
|
984
|
+
sys.stderr.write(
|
|
985
|
+
f"the chain was read and then stopped answering: {consultation.problem.message}\n"
|
|
986
|
+
)
|
|
987
|
+
item.refused = True
|
|
988
|
+
raise RuntimeError(f"{UNGOVERNED} effect: {item.point.capability} ({item.point.audit_event})")
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _consulted(chain: Chain, capability: str) -> Consultation:
|
|
992
|
+
"""Poll the chain for a record this effect has not already been answered by.
|
|
993
|
+
|
|
994
|
+
Polled, because article 10 writes the chain asynchronously: the record of
|
|
995
|
+
an effect the plane allowed may arrive after the effect was allowed to
|
|
996
|
+
happen.
|
|
997
|
+
|
|
998
|
+
Every read that was not answered is remembered, and so is whether ANY was.
|
|
999
|
+
A consultation that read nothing at all across the whole patience has
|
|
1000
|
+
established nothing about the program — not even an absence — which is why
|
|
1001
|
+
that is a third outcome here and not a `False`.
|
|
1002
|
+
"""
|
|
1003
|
+
deadline = time.monotonic() + CHAIN_WAIT
|
|
1004
|
+
read_something = False
|
|
1005
|
+
problem: Problem | None = None
|
|
1006
|
+
with chain.lock:
|
|
1007
|
+
while True:
|
|
1008
|
+
found, answered, failure = _matching(chain, capability)
|
|
1009
|
+
read_something = read_something or answered
|
|
1010
|
+
if failure is not None:
|
|
1011
|
+
problem = failure
|
|
1012
|
+
if found is not None:
|
|
1013
|
+
chain.cursor = found
|
|
1014
|
+
return Consultation(found, True, None)
|
|
1015
|
+
if time.monotonic() >= deadline:
|
|
1016
|
+
return Consultation(None, read_something, problem)
|
|
1017
|
+
time.sleep(POLL_INTERVAL)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _matching(chain: Chain, capability: str) -> tuple[int | None, bool, Problem | None]:
|
|
1021
|
+
"""One walk: the sequence matched, whether any page was answered, and the problem.
|
|
1022
|
+
|
|
1023
|
+
Nothing is inferred from a read that was not answered — not here and not in
|
|
1024
|
+
the poll above, which is what carries « no read succeeded » out to the
|
|
1025
|
+
caller instead of letting it look like « no record exists ».
|
|
1026
|
+
"""
|
|
1027
|
+
answered = False
|
|
1028
|
+
from_sequence = chain.cursor + 1
|
|
1029
|
+
while True:
|
|
1030
|
+
result = reads.read(lambda at=from_sequence: _one_page(chain, at))
|
|
1031
|
+
if not isinstance(result, Answered):
|
|
1032
|
+
return None, answered, result.problem
|
|
1033
|
+
answered = True
|
|
1034
|
+
entries, _, next_from = result.value
|
|
1035
|
+
for entry in entries:
|
|
1036
|
+
sequence = entry.get("sequence")
|
|
1037
|
+
body = entry.get("body")
|
|
1038
|
+
if (
|
|
1039
|
+
isinstance(sequence, int)
|
|
1040
|
+
and sequence > chain.cursor
|
|
1041
|
+
and entry.get("kind") == EFFECT
|
|
1042
|
+
and isinstance(body, Mapping)
|
|
1043
|
+
and body.get("capability") == capability
|
|
1044
|
+
and body.get("outcome") == ALLOW
|
|
1045
|
+
):
|
|
1046
|
+
return sequence, True, None
|
|
1047
|
+
if next_from is None:
|
|
1048
|
+
return None, answered, None
|
|
1049
|
+
from_sequence = next_from
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _write_report(
|
|
1053
|
+
configured: Configuration,
|
|
1054
|
+
packs: Sequence[manifest.Pack],
|
|
1055
|
+
watched: Sequence[Watched],
|
|
1056
|
+
start_sequence: int,
|
|
1057
|
+
target_exit: int | None,
|
|
1058
|
+
connection: VerifiedConnection,
|
|
1059
|
+
) -> None:
|
|
1060
|
+
"""Write what this run observed, whatever ended it.
|
|
1061
|
+
|
|
1062
|
+
Written from a `finally`, because the interesting run is the one the target
|
|
1063
|
+
did not survive: an effect the hook aborted kills the program, and a proof
|
|
1064
|
+
that lost its own findings to the failure it caused would prove nothing.
|
|
1065
|
+
|
|
1066
|
+
`inspected` is read off the points that were actually watched for rather
|
|
1067
|
+
than off the command line. Article 9 asks the public gate to fail unless
|
|
1068
|
+
the verifier inspected every shipped pack, and a set copied from the
|
|
1069
|
+
invocation would satisfy that assertion without having watched anything.
|
|
1070
|
+
"""
|
|
1071
|
+
inspected = sorted({item.pack for item in watched})
|
|
1072
|
+
document = {
|
|
1073
|
+
"packs": [pack.name for pack in packs],
|
|
1074
|
+
"inspected": inspected,
|
|
1075
|
+
"points": [item.to_document() for item in watched],
|
|
1076
|
+
"target_exit": target_exit,
|
|
1077
|
+
"start_sequence": start_sequence,
|
|
1078
|
+
"verification": render.verification_document(
|
|
1079
|
+
connection.server_credential.uid, connection.expected_uid, connection.verified
|
|
1080
|
+
),
|
|
1081
|
+
}
|
|
1082
|
+
configured.report.write_text(
|
|
1083
|
+
json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _asked(forwarded: Sequence[str]) -> tuple[Configuration, list[str]]:
|
|
1088
|
+
"""The configuration and the program, or the misuse that there is neither."""
|
|
1089
|
+
argv = list(forwarded)
|
|
1090
|
+
if not argv:
|
|
1091
|
+
raise HarnessMisuse(
|
|
1092
|
+
f"this harness is run as `<configuration> {SEPARATOR} <target…>`, and was given nothing"
|
|
1093
|
+
)
|
|
1094
|
+
if SEPARATOR not in argv[1:]:
|
|
1095
|
+
raise HarnessMisuse(
|
|
1096
|
+
f"this harness was given no `{SEPARATOR}`: the program to prove follows it"
|
|
1097
|
+
)
|
|
1098
|
+
at = argv.index(SEPARATOR, 1)
|
|
1099
|
+
return _configured(Path(argv[0])), argv[at + 1 :]
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _configured(path: Path) -> Configuration:
|
|
1103
|
+
"""Read the configuration, naming the member and the rule on every refusal."""
|
|
1104
|
+
try:
|
|
1105
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
1106
|
+
except (OSError, UnicodeDecodeError, ValueError, RecursionError) as unreadable:
|
|
1107
|
+
raise HarnessMisuse(
|
|
1108
|
+
f"{path} does not read as a configuration: {unreadable}"
|
|
1109
|
+
) from unreadable
|
|
1110
|
+
if not isinstance(document, Mapping):
|
|
1111
|
+
raise HarnessMisuse(f"{path} is not a configuration: it declares no members")
|
|
1112
|
+
declared = {member: None for member in CONFIGURED} | dict(document)
|
|
1113
|
+
packs = declared["packs"]
|
|
1114
|
+
if not isinstance(packs, list) or not packs or not all(isinstance(it, str) for it in packs):
|
|
1115
|
+
raise HarnessMisuse(
|
|
1116
|
+
f"packs is {packs!r}: a verification names the pack directories it watches for, "
|
|
1117
|
+
f"as a non-empty list of paths — one that watched for nothing would be the "
|
|
1118
|
+
f"vacuous proof article 9 forbids"
|
|
1119
|
+
)
|
|
1120
|
+
for member in ("socket", "scope", "mode"):
|
|
1121
|
+
if not isinstance(declared[member], str) or not declared[member]:
|
|
1122
|
+
raise HarnessMisuse(f"{member} is {declared[member]!r}: it names a non-empty string")
|
|
1123
|
+
for member in ("daemon_user", "principal"):
|
|
1124
|
+
if declared[member] is not None and not isinstance(declared[member], str):
|
|
1125
|
+
raise HarnessMisuse(f"{member} is {declared[member]!r}: it is a string or absent")
|
|
1126
|
+
if not isinstance(declared["governed"], bool):
|
|
1127
|
+
raise HarnessMisuse(
|
|
1128
|
+
f"governed is {declared['governed']!r}: it says whether the program is handed over "
|
|
1129
|
+
f"with the boundary in front of it, as true or false and never as an absence"
|
|
1130
|
+
)
|
|
1131
|
+
if not isinstance(declared["report"], str) or not declared["report"]:
|
|
1132
|
+
raise HarnessMisuse(
|
|
1133
|
+
f"report is {declared['report']!r}: it names the path this run's findings are "
|
|
1134
|
+
f"written to, and a run that wrote them nowhere would prove nothing"
|
|
1135
|
+
)
|
|
1136
|
+
if not isinstance(declared[OUTCOME_FILE_MEMBER], str) or not declared[OUTCOME_FILE_MEMBER]:
|
|
1137
|
+
raise HarnessMisuse(
|
|
1138
|
+
f"{OUTCOME_FILE_MEMBER} is {declared[OUTCOME_FILE_MEMBER]!r}: it names the path "
|
|
1139
|
+
f"this run says its own ending on, and a run that said it nowhere would leave "
|
|
1140
|
+
f"the command reading the target's exit status for it"
|
|
1141
|
+
)
|
|
1142
|
+
try:
|
|
1143
|
+
profile = SocketProfile(
|
|
1144
|
+
declared["socket"],
|
|
1145
|
+
mode=declared["mode"],
|
|
1146
|
+
daemon_user=declared["daemon_user"],
|
|
1147
|
+
scope=declared["scope"],
|
|
1148
|
+
)
|
|
1149
|
+
except ProfileMisuse as invalid:
|
|
1150
|
+
raise HarnessMisuse(str(invalid)) from invalid
|
|
1151
|
+
return Configuration(
|
|
1152
|
+
packs=tuple(Path(it) for it in packs),
|
|
1153
|
+
profile=profile,
|
|
1154
|
+
principal=declared["principal"],
|
|
1155
|
+
governed=declared["governed"],
|
|
1156
|
+
report=Path(declared["report"]),
|
|
1157
|
+
outcome=Path(declared[OUTCOME_FILE_MEMBER]),
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
if __name__ == "__main__": # pragma: no cover - exercised as a subprocess
|
|
1162
|
+
raise SystemExit(main())
|