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,88 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""One process exit code per thing that can come back, all of them distinct.
|
|
3
|
+
|
|
4
|
+
Article 1 names this file's guard: "The project's client renders 'denied' and
|
|
5
|
+
'could not ask' as distinct results with distinct exit codes, and a test holds
|
|
6
|
+
them apart." Article 2 is why the list is longer than two: an answer, a refusal
|
|
7
|
+
of the question and an unanswerable question are three different facts, and a
|
|
8
|
+
shell that collapses them into "non-zero" has lost the one distinction the
|
|
9
|
+
constitution insists on.
|
|
10
|
+
|
|
11
|
+
Three of these codes are not this repository's to choose. `sayfirst whoami`,
|
|
12
|
+
which the contract distribution implements, already publishes `0`, `3`, `4` and
|
|
13
|
+
`64` for the same four situations; a second client of the same contract that
|
|
14
|
+
numbered them differently would make the same event read two ways depending on
|
|
15
|
+
which subcommand produced it. So they are taken as given; the codes for the
|
|
16
|
+
three outcomes and the local checks are added here.
|
|
17
|
+
|
|
18
|
+
Article 1 also keeps a local check apart from an answer. A broken chain or a
|
|
19
|
+
contradicted verdict is something this client found, not a denial the plane
|
|
20
|
+
gave. A check that cannot conclude is not a failure to ask the plane either:
|
|
21
|
+
the record may have arrived, while its version or coverage prevents a check.
|
|
22
|
+
|
|
23
|
+
`2` is absent on purpose: `argparse` exits with it on a usage error it rejects
|
|
24
|
+
itself, before any of this runs. Claiming it for an outcome would make a
|
|
25
|
+
mistyped flag indistinguishable from an answer.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Final
|
|
31
|
+
|
|
32
|
+
#: The control plane answered `allow`. The only code that means "go ahead".
|
|
33
|
+
EXIT_ALLOW: Final[int] = 0
|
|
34
|
+
|
|
35
|
+
#: The control plane answered `deny`. An answer, not a failure to obtain one.
|
|
36
|
+
EXIT_DENY: Final[int] = 1
|
|
37
|
+
|
|
38
|
+
#: The request was refused — the question was received and rejected.
|
|
39
|
+
#: The value `sayfirst whoami` publishes for the same situation.
|
|
40
|
+
EXIT_REFUSED: Final[int] = 3
|
|
41
|
+
|
|
42
|
+
#: The control plane could not be asked, or answered something this generation
|
|
43
|
+
#: cannot read. Never rendered as a denial (articles 1 and 2). The value
|
|
44
|
+
#: `sayfirst whoami` publishes for the same situation.
|
|
45
|
+
EXIT_COULD_NOT_ASK: Final[int] = 4
|
|
46
|
+
|
|
47
|
+
#: The control plane answered `suspend`: the effect waits for a person.
|
|
48
|
+
EXIT_SUSPEND: Final[int] = 5
|
|
49
|
+
|
|
50
|
+
# A successful read exits 0 without another zero-valued name in CODES: a read's
|
|
51
|
+
# success is not an outcome; the record's own outcome is data, not permission.
|
|
52
|
+
#: A local verification found what the plane did not say: a broken chain, an
|
|
53
|
+
#: undeclared gap, a manifest that does not recompute, or a served verdict the
|
|
54
|
+
#: local check contradicts. A finding, never a denial (article 1).
|
|
55
|
+
EXIT_CHECK_FAILED: Final[int] = 6
|
|
56
|
+
|
|
57
|
+
#: The local check could not conclude: an unknown preimage or manifest version,
|
|
58
|
+
#: or insufficient coverage. An unknown check, not an unanswerable question.
|
|
59
|
+
EXIT_COULD_NOT_CHECK: Final[int] = 7
|
|
60
|
+
|
|
61
|
+
#: The invocation was wrong, or something it named cannot be read — a profile
|
|
62
|
+
#: that cannot say what it must verify, a pack manifest that does not parse, a
|
|
63
|
+
#: pack this distribution ships that will not read. The value `sayfirst whoami`
|
|
64
|
+
#: publishes for the same situation; `packs list` uses it for a broken shipped
|
|
65
|
+
#: pack rather than minting a code for a case no caller can act on differently,
|
|
66
|
+
#: because a distinct number would cost one in a table three commands share and
|
|
67
|
+
#: the paragraph above says why the numbers here are not this repository's alone
|
|
68
|
+
#: to choose. « The invocation was wrong » is a stretch for a pack the
|
|
69
|
+
#: DISTRIBUTION ships, and it is the honest half of the answer: the caller typed
|
|
70
|
+
#: nothing wrong, and something the command named cannot be read.
|
|
71
|
+
EXIT_MISUSE: Final[int] = 64
|
|
72
|
+
|
|
73
|
+
#: The code `argparse` uses for a usage error it rejects itself. Reserved
|
|
74
|
+
#: rather than assigned, so that nothing here can collide with it.
|
|
75
|
+
EXIT_PARSER_USAGE: Final[int] = 2
|
|
76
|
+
|
|
77
|
+
#: Every code this client can exit with, by the name of what it means.
|
|
78
|
+
CODES: Final[dict[str, int]] = {
|
|
79
|
+
"allow": EXIT_ALLOW,
|
|
80
|
+
"deny": EXIT_DENY,
|
|
81
|
+
"refused": EXIT_REFUSED,
|
|
82
|
+
"could_not_ask": EXIT_COULD_NOT_ASK,
|
|
83
|
+
"suspend": EXIT_SUSPEND,
|
|
84
|
+
"check_failed": EXIT_CHECK_FAILED,
|
|
85
|
+
"could_not_check": EXIT_COULD_NOT_CHECK,
|
|
86
|
+
"misuse": EXIT_MISUSE,
|
|
87
|
+
"parser_usage": EXIT_PARSER_USAGE,
|
|
88
|
+
}
|
sayfirst_cli/explain.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Read a decision's reason, rule and policy version in the plane's own words."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import TextIO
|
|
10
|
+
|
|
11
|
+
from . import reads, render
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="sayfirst explain", description="Read the record and reason of a decision."
|
|
17
|
+
)
|
|
18
|
+
reads.add_connection_arguments(parser)
|
|
19
|
+
parser.add_argument("--decision", required=True, help="the reference of the recorded decision")
|
|
20
|
+
return parser
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(
|
|
24
|
+
argv: Sequence[str] | None = None, *, out: TextIO | None = None, err: TextIO | None = None
|
|
25
|
+
) -> int:
|
|
26
|
+
stdout, stderr = out or sys.stdout, err or sys.stderr
|
|
27
|
+
arguments = build_parser().parse_args(argv)
|
|
28
|
+
connection = reads.open_connection(arguments, stderr)
|
|
29
|
+
if isinstance(connection, int):
|
|
30
|
+
return connection
|
|
31
|
+
try:
|
|
32
|
+
result = reads.read(lambda: connection.read_decision(arguments.scope, arguments.decision))
|
|
33
|
+
return reads.finish(result, arguments, connection, stdout, stderr, render.write_record)
|
|
34
|
+
finally:
|
|
35
|
+
connection.close()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The instrumentation chain's client side: the manifest, the engine, the verifier.
|
|
3
|
+
|
|
4
|
+
Article 9 says the open project ships the instrumentation engine, the
|
|
5
|
+
instrumentation verifier and the convenience packs. Two of the three live here.
|
|
6
|
+
|
|
7
|
+
Layer 1 is `manifest`, `engine` and `launch`: they read what a pack declares,
|
|
8
|
+
install it in front of the named operations, and hand the program over. Layer 3
|
|
9
|
+
is `verify` and `harness`: they run the program again under the interpreter's
|
|
10
|
+
own audit hook and ask the daemon's chain whether every effect of a named kind
|
|
11
|
+
was decided. The two do not meet — the verifier consumes audit events and
|
|
12
|
+
evidence and nothing the engine kept — because a proof that trusts the thing it
|
|
13
|
+
is proving is not a proof.
|
|
14
|
+
"""
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`sayfirst instrument`: run somebody's program with the boundary in front of it.
|
|
3
|
+
|
|
4
|
+
Three verbs, because one word cannot carry two meanings. Article 9 writes
|
|
5
|
+
« `sayfirst instrument apply` executes it » while also making runtime
|
|
6
|
+
interposition the primary mode and a committed code modification a later option
|
|
7
|
+
— and a verb that undoes itself when the process ends is not the same act as a
|
|
8
|
+
verb that edits somebody's repository. `apply` is the ordinary name for the
|
|
9
|
+
second, so it keeps the article's name for the article's later option and says,
|
|
10
|
+
in as many words, that it is not that option yet (the architecture reading of
|
|
11
|
+
2026-09-14). Nothing is renamed into meaning its opposite, and a reader who
|
|
12
|
+
disagrees has an amendment to write rather than a redefinition to find in code.
|
|
13
|
+
|
|
14
|
+
The codes this command produces come from two places and never a third: the
|
|
15
|
+
invocation's own mistakes, which are this client's to report, and the governed
|
|
16
|
+
program's ending, which is the program's and is passed through untouched.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Final, TextIO
|
|
26
|
+
|
|
27
|
+
from sayfirst_contract.client import Refused
|
|
28
|
+
from sayfirst_contract.generation import CONTRACT_GENERATION
|
|
29
|
+
from sayfirst_contract.transport.socket_client import (
|
|
30
|
+
PER_USER,
|
|
31
|
+
SYSTEM,
|
|
32
|
+
ProfileMisuse,
|
|
33
|
+
SocketClientProblem,
|
|
34
|
+
SocketProfile,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from .. import exit_codes, reads, render
|
|
38
|
+
from . import engine, launch, manifest, verify
|
|
39
|
+
|
|
40
|
+
#: What `apply` says instead of doing anything. The sentence names the mode that
|
|
41
|
+
#: does exist, because a refusal that leaves the reader with no next step is
|
|
42
|
+
#: only half of what article 2 asks of an absence.
|
|
43
|
+
APPLY_REFUSAL: Final[str] = (
|
|
44
|
+
"instrument apply is reserved for the committed code modification, which does not "
|
|
45
|
+
"exist yet; use `instrument run`, the reversible mode (architecture reading, 2026-09-14)"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
#: The verbs that do nothing, and the sentence each says instead. They are
|
|
49
|
+
#: answered before the parser reads their arguments, because they refuse the
|
|
50
|
+
#: ACT: a usage error about an option would suggest that some other spelling of
|
|
51
|
+
#: the same act would be accepted, and none would. `verify` left this table
|
|
52
|
+
#: when the verifier arrived; `apply` stays until the committed code
|
|
53
|
+
#: modification does.
|
|
54
|
+
RESERVED: Final[dict[str, str]] = {"apply": APPLY_REFUSAL}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
58
|
+
"""Every verb this command answers, and only the ones it answers."""
|
|
59
|
+
parser = argparse.ArgumentParser(
|
|
60
|
+
prog="sayfirst instrument",
|
|
61
|
+
description="Run a program with the sayfirst boundary in front of named effects.",
|
|
62
|
+
)
|
|
63
|
+
verbs = parser.add_subparsers(dest="verb", required=True)
|
|
64
|
+
running = verbs.add_parser("run", help="run a program with the designated packs installed")
|
|
65
|
+
running.add_argument(
|
|
66
|
+
"--pack",
|
|
67
|
+
action="append",
|
|
68
|
+
required=True,
|
|
69
|
+
metavar="DIR",
|
|
70
|
+
help="a pack directory; repeat the option for each pack",
|
|
71
|
+
)
|
|
72
|
+
running.add_argument("--scope", required=True, help="the scope the questions are asked in")
|
|
73
|
+
running.add_argument("--socket", required=True, help="the path of the daemon's socket")
|
|
74
|
+
running.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER)
|
|
75
|
+
running.add_argument(
|
|
76
|
+
"--daemon-user",
|
|
77
|
+
default=None,
|
|
78
|
+
help="the account the daemon runs as; a system profile names it",
|
|
79
|
+
)
|
|
80
|
+
running.add_argument(
|
|
81
|
+
"--principal",
|
|
82
|
+
default=None,
|
|
83
|
+
metavar="REF",
|
|
84
|
+
help="the principal reference to hold grants against; this account by default",
|
|
85
|
+
)
|
|
86
|
+
running.add_argument(
|
|
87
|
+
"target",
|
|
88
|
+
nargs="*",
|
|
89
|
+
metavar="TARGET",
|
|
90
|
+
help="after `--`: either -m MODULE [args] or SCRIPT [args]",
|
|
91
|
+
)
|
|
92
|
+
verify.add_arguments(
|
|
93
|
+
verbs.add_parser("verify", help="prove every named effect of a program was decided")
|
|
94
|
+
)
|
|
95
|
+
# Declared so that `--help` names every verb this command answers, article 2's
|
|
96
|
+
# rule about a help text being a claim. `main` answers this one before the
|
|
97
|
+
# parser is asked to read anything after it.
|
|
98
|
+
verbs.add_parser("apply", help="the committed code modification (reserved; it refuses)")
|
|
99
|
+
return parser
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(
|
|
103
|
+
argv: Sequence[str] | None = None, *, out: TextIO | None = None, err: TextIO | None = None
|
|
104
|
+
) -> int:
|
|
105
|
+
"""Run the verb and return the code the calling shell should see."""
|
|
106
|
+
stdout = out or sys.stdout
|
|
107
|
+
stderr = err or sys.stderr
|
|
108
|
+
forwarded = list(sys.argv[1:] if argv is None else argv)
|
|
109
|
+
if forwarded and forwarded[0] in RESERVED:
|
|
110
|
+
stderr.write(f"{RESERVED[forwarded[0]]}\n")
|
|
111
|
+
return exit_codes.EXIT_MISUSE
|
|
112
|
+
return _run(build_parser().parse_args(forwarded), stdout, stderr)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _run(arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO) -> int:
|
|
116
|
+
"""Read the packs, open the profile, and hand the program over.
|
|
117
|
+
|
|
118
|
+
Everything that can be wrong with the invocation is found before a single
|
|
119
|
+
attribute is replaced — with the one exception the engine names: an
|
|
120
|
+
attribute a pack declares on a module the program only imports later
|
|
121
|
+
cannot be checked before that import, and is refused when it happens. A
|
|
122
|
+
program half instrumented would be running partly governed with nothing
|
|
123
|
+
saying which part, which is worse than not running.
|
|
124
|
+
"""
|
|
125
|
+
if arguments.verb == "verify":
|
|
126
|
+
# Layer 3 owns its own codes: they are about the proof, never about the
|
|
127
|
+
# program's ending, which `run` below is the one that passes through.
|
|
128
|
+
return verify.run(arguments, stdout, stderr)
|
|
129
|
+
packs: list[manifest.Pack] = []
|
|
130
|
+
for named in arguments.pack:
|
|
131
|
+
try:
|
|
132
|
+
packs.append(manifest.read_pack(Path(named)))
|
|
133
|
+
except manifest.PackInvalid as invalid:
|
|
134
|
+
# The path as it was TYPED, not as it was resolved: with more than
|
|
135
|
+
# one `--pack` the sentence alone does not say which one to fix, and
|
|
136
|
+
# a resolved path is not what the reader has in their shell history.
|
|
137
|
+
stderr.write(f"{named}: {invalid}\n")
|
|
138
|
+
return exit_codes.EXIT_MISUSE
|
|
139
|
+
try:
|
|
140
|
+
profile = SocketProfile(
|
|
141
|
+
arguments.socket,
|
|
142
|
+
mode=arguments.mode,
|
|
143
|
+
daemon_user=arguments.daemon_user,
|
|
144
|
+
scope=arguments.scope,
|
|
145
|
+
)
|
|
146
|
+
except ProfileMisuse as invalid:
|
|
147
|
+
stderr.write(f"{invalid}\n")
|
|
148
|
+
return exit_codes.EXIT_MISUSE
|
|
149
|
+
try:
|
|
150
|
+
return launch.run(
|
|
151
|
+
packs,
|
|
152
|
+
profile,
|
|
153
|
+
arguments.target,
|
|
154
|
+
principal=arguments.principal,
|
|
155
|
+
out=stdout,
|
|
156
|
+
err=stderr,
|
|
157
|
+
)
|
|
158
|
+
except launch.LaunchMisuse as misuse:
|
|
159
|
+
# Everything the launcher refuses BEFORE the hand-off: a point the
|
|
160
|
+
# engine will not install, an execution module that will not load, a
|
|
161
|
+
# target that names no program. The program has not started, so this is
|
|
162
|
+
# the invocation's mistake and not an outcome about an effect.
|
|
163
|
+
stderr.write(f"{misuse}\n")
|
|
164
|
+
return exit_codes.EXIT_MISUSE
|
|
165
|
+
except engine.EngineMisuse as misuse:
|
|
166
|
+
# A point naming an attribute a module does not have, discovered inside
|
|
167
|
+
# the program's own import — the one check that cannot be made before
|
|
168
|
+
# the hand-off (the engine says why). The pack is the invocation's and
|
|
169
|
+
# the program is innocent: no question was ever put, so it is 64, the
|
|
170
|
+
# same answer this file already gives the byte-identical shape of a
|
|
171
|
+
# `-m` name that resolves to a package with no `__main__`. Uncaught, it
|
|
172
|
+
# reached a shell as a traceback and exit 1, this client's published
|
|
173
|
+
# code for « deny », over a mistake the plane was never asked about.
|
|
174
|
+
stderr.write(f"{misuse}\n")
|
|
175
|
+
return exit_codes.EXIT_MISUSE
|
|
176
|
+
except SocketClientProblem as failure:
|
|
177
|
+
# Raised while the connection was being arranged, so no question was
|
|
178
|
+
# ever put. It is reported with the contract's own classification and
|
|
179
|
+
# never as a denial (articles 1 and 2); a refusal the boundary raises
|
|
180
|
+
# later, inside the program, is the program's and is not caught here.
|
|
181
|
+
return _write_problem(failure, stderr)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _write_problem(failure: SocketClientProblem, stderr: TextIO) -> int:
|
|
185
|
+
"""A non-answer, said as the reads say it, with the code the reads use."""
|
|
186
|
+
result = reads.connection_problem(failure)
|
|
187
|
+
could_not_ask = not isinstance(result, Refused)
|
|
188
|
+
render.write_problem(
|
|
189
|
+
result.problem.to_document(CONTRACT_GENERATION), stderr, could_not_ask=could_not_ask
|
|
190
|
+
)
|
|
191
|
+
return exit_codes.EXIT_COULD_NOT_ASK if could_not_ask else exit_codes.EXIT_REFUSED
|