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
sayfirst_cli/__init__.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Read a suspended request, approve it, reject it — one person, nothing more.
|
|
3
|
+
|
|
4
|
+
Article 12's simple form: no signatures, no designation, one person's act. This
|
|
5
|
+
module reads the same way `trace` and `explain` do — through `reads.read` and
|
|
6
|
+
`reads.finish` — because `show` is exactly that shape, and `approve`/`reject`
|
|
7
|
+
answer the same document a `show` would: the record the act left behind, so a
|
|
8
|
+
caller renders one writer whichever of the three it asked for.
|
|
9
|
+
|
|
10
|
+
Unlike `read_decision`, the daemon does NOT close the connection after
|
|
11
|
+
`read_approval` or `resolve_approval` (the transport's own docstrings say so):
|
|
12
|
+
it writes both answers through its own handler and leaves the connection for
|
|
13
|
+
the next request. That is what lets a person read a wait and then end it
|
|
14
|
+
without reconnecting — and it is the daemon's behaviour to rely on, not a
|
|
15
|
+
promise the daemon owes across every future release. One command here ever
|
|
16
|
+
does one thing, so nothing in this module reads twice on the same connection
|
|
17
|
+
and rule C4's explicit reconnect never comes up.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
25
|
+
from typing import Final, TextIO
|
|
26
|
+
|
|
27
|
+
from sayfirst_contract.approvals import ApprovalResolution, Resolution
|
|
28
|
+
|
|
29
|
+
from . import reads, render
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _stated(value: object) -> str:
|
|
33
|
+
"""A member as a person reads it, with absence said rather than shown.
|
|
34
|
+
|
|
35
|
+
The same rule `render._stated` applies, spelled here rather than imported:
|
|
36
|
+
that helper is render's own, and a second module reaching into it is a
|
|
37
|
+
second module that breaks when its name does.
|
|
38
|
+
"""
|
|
39
|
+
return render.NOT_STATED if value is None else str(value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _write_approval(document: Mapping[str, object], stream: TextIO) -> None:
|
|
43
|
+
"""The seven members a person reads about one wait — read or resolved alike."""
|
|
44
|
+
stream.write(f"approval: {document['approval_ref']}\n")
|
|
45
|
+
stream.write(f"decision: {document['decision_ref']}\n")
|
|
46
|
+
stream.write(f"state: {document['state']}\n")
|
|
47
|
+
stream.write(f"requested_at: {document['requested_at']}\n")
|
|
48
|
+
stream.write(f"deadline: {document['deadline']}\n")
|
|
49
|
+
stream.write(f"resolved_at: {_stated(document.get('resolved_at'))}\n")
|
|
50
|
+
stream.write(f"reason: {_stated(document.get('resolution_reason'))}\n")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _connection_parser(prog: str) -> argparse.ArgumentParser:
|
|
54
|
+
"""Every `approvals` subcommand shares the connection options and `--approval`."""
|
|
55
|
+
parser = argparse.ArgumentParser(prog=prog)
|
|
56
|
+
reads.add_connection_arguments(parser)
|
|
57
|
+
parser.add_argument("--approval", required=True, help="the reference of the suspended approval")
|
|
58
|
+
return parser
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _show(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int:
|
|
62
|
+
parser = _connection_parser("sayfirst approvals show")
|
|
63
|
+
arguments = parser.parse_args(argv)
|
|
64
|
+
connection = reads.open_connection(arguments, err)
|
|
65
|
+
if isinstance(connection, int):
|
|
66
|
+
return connection
|
|
67
|
+
try:
|
|
68
|
+
result = reads.read(lambda: connection.read_approval(arguments.scope, arguments.approval))
|
|
69
|
+
return reads.finish(result, arguments, connection, out, err, _write_approval)
|
|
70
|
+
finally:
|
|
71
|
+
connection.close()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve(argv: Sequence[str], *, out: TextIO, err: TextIO, resolution: Resolution) -> int:
|
|
75
|
+
parser = _connection_parser(f"sayfirst approvals {resolution.value}")
|
|
76
|
+
parser.add_argument("--reason", default=None, help="why this decision was made")
|
|
77
|
+
arguments = parser.parse_args(argv)
|
|
78
|
+
connection = reads.open_connection(arguments, err)
|
|
79
|
+
if isinstance(connection, int):
|
|
80
|
+
return connection
|
|
81
|
+
try:
|
|
82
|
+
act = ApprovalResolution(arguments.scope, arguments.approval, resolution, arguments.reason)
|
|
83
|
+
# Put exactly once (the transport's own rule): a failure here is
|
|
84
|
+
# reported as what the transport classified it, never retried by this
|
|
85
|
+
# command on the caller's behalf.
|
|
86
|
+
result = reads.read(lambda: connection.resolve_approval(act))
|
|
87
|
+
return reads.finish(result, arguments, connection, out, err, _write_approval)
|
|
88
|
+
finally:
|
|
89
|
+
connection.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _approve(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int:
|
|
93
|
+
return _resolve(argv, out=out, err=err, resolution=Resolution.APPROVE)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _reject(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int:
|
|
97
|
+
return _resolve(argv, out=out, err=err, resolution=Resolution.REJECT)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
#: The three acts this distribution answers, each with the entry point that
|
|
101
|
+
#: owns its parser and its exit codes.
|
|
102
|
+
COMMANDS: Final[dict[str, Callable[..., int]]] = {
|
|
103
|
+
"show": _show,
|
|
104
|
+
"approve": _approve,
|
|
105
|
+
"reject": _reject,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
110
|
+
parser = argparse.ArgumentParser(
|
|
111
|
+
prog="sayfirst approvals",
|
|
112
|
+
description="Read a suspended request, approve it, or reject it.",
|
|
113
|
+
)
|
|
114
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
115
|
+
for name in COMMANDS:
|
|
116
|
+
commands.add_parser(name, add_help=False)
|
|
117
|
+
return parser
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main(
|
|
121
|
+
argv: Sequence[str] | None = None, *, out: TextIO | None = None, err: TextIO | None = None
|
|
122
|
+
) -> int:
|
|
123
|
+
forwarded = list(sys.argv[1:] if argv is None else argv)
|
|
124
|
+
if forwarded and (command := COMMANDS.get(forwarded[0])) is not None:
|
|
125
|
+
return command(forwarded[1:], out=out or sys.stdout, err=err or sys.stderr)
|
|
126
|
+
build_parser().parse_args(forwarded)
|
|
127
|
+
raise AssertionError("argparse accepted an approvals command that has no entry point")
|
sayfirst_cli/ask.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`sayfirst ask`: put one question to the control plane and render its answer.
|
|
3
|
+
|
|
4
|
+
This is the product act. A person names a capability and a scope, this client
|
|
5
|
+
opens the daemon's socket, verifies the far end is the daemon's principal before
|
|
6
|
+
it writes anything, sends the question the contract defines, and renders the
|
|
7
|
+
answer the control plane gave — `allow`, `deny` or `suspend` — with an exit code
|
|
8
|
+
per outcome.
|
|
9
|
+
|
|
10
|
+
What this command does **not** do is the point of it. It holds no policy, it
|
|
11
|
+
caches no decision, and when the daemon cannot be reached it says so; it never
|
|
12
|
+
turns "could not ask" into "deny" and never turns it into "allow" (articles 1
|
|
13
|
+
and 2). There is no `--url` and nothing that takes a token: the boundary says
|
|
14
|
+
who the caller is, and it says so from the socket (article 6).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from typing import Final, TextIO
|
|
23
|
+
|
|
24
|
+
from sayfirst_contract.client import Answered, Refused
|
|
25
|
+
from sayfirst_contract.decisions import DecisionAsk, Outcome
|
|
26
|
+
from sayfirst_contract.generation import CONTRACT_GENERATION
|
|
27
|
+
from sayfirst_contract.transport.socket_client import (
|
|
28
|
+
PER_USER,
|
|
29
|
+
SYSTEM,
|
|
30
|
+
ProfileMisuse,
|
|
31
|
+
SocketClientProblem,
|
|
32
|
+
SocketProfile,
|
|
33
|
+
connect,
|
|
34
|
+
declared_delegation,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from . import exit_codes, reads, render
|
|
38
|
+
|
|
39
|
+
#: One exit code per outcome the contract defines. `tests/test_exit_codes.py`
|
|
40
|
+
#: holds this map against `Outcome`, so the fallback below is unreachable in a
|
|
41
|
+
#: build whose contract and client agree.
|
|
42
|
+
EXIT_BY_OUTCOME: Final[dict[Outcome, int]] = {
|
|
43
|
+
Outcome.ALLOW: exit_codes.EXIT_ALLOW,
|
|
44
|
+
Outcome.DENY: exit_codes.EXIT_DENY,
|
|
45
|
+
Outcome.SUSPEND: exit_codes.EXIT_SUSPEND,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
50
|
+
"""Every option this command accepts. None of them names a network."""
|
|
51
|
+
parser = argparse.ArgumentParser(
|
|
52
|
+
prog="sayfirst ask",
|
|
53
|
+
description="Ask the control plane whether one capability may be exercised.",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("--capability", required=True, help="the kind of effect, and nothing more")
|
|
56
|
+
parser.add_argument("--scope", default="local", help="the scope the question is asked in")
|
|
57
|
+
parser.add_argument("--socket", required=True, help="the path of the daemon's socket")
|
|
58
|
+
parser.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--daemon-user",
|
|
61
|
+
default=None,
|
|
62
|
+
help="the account the daemon runs as; a system profile names it",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument("--arguments-digest", default=None)
|
|
65
|
+
parser.add_argument("--correlation", default=None)
|
|
66
|
+
parser.add_argument("--json", action="store_true", help="write the envelope instead of prose")
|
|
67
|
+
return parser
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _exit_for_answer(outcome: Outcome) -> int:
|
|
71
|
+
"""The code for an outcome, failing closed on one this client cannot render.
|
|
72
|
+
|
|
73
|
+
An outcome the contract defines and this map has forgotten is a defect of
|
|
74
|
+
this file, not an answer: it is reported the way an unreadable answer is,
|
|
75
|
+
because the one thing it must never do is exit zero (articles 1 and 3).
|
|
76
|
+
"""
|
|
77
|
+
return EXIT_BY_OUTCOME.get(outcome, exit_codes.EXIT_COULD_NOT_ASK)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(
|
|
81
|
+
argv: Sequence[str] | None = None, *, out: TextIO | None = None, err: TextIO | None = None
|
|
82
|
+
) -> int:
|
|
83
|
+
"""Run the command and return the code the calling shell should see."""
|
|
84
|
+
stdout = out or sys.stdout
|
|
85
|
+
stderr = err or sys.stderr
|
|
86
|
+
arguments = build_parser().parse_args(argv)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
profile = SocketProfile(
|
|
90
|
+
arguments.socket,
|
|
91
|
+
mode=arguments.mode,
|
|
92
|
+
daemon_user=arguments.daemon_user,
|
|
93
|
+
scope=arguments.scope,
|
|
94
|
+
)
|
|
95
|
+
except ProfileMisuse as misuse:
|
|
96
|
+
stderr.write(f"{misuse}\n")
|
|
97
|
+
return exit_codes.EXIT_MISUSE
|
|
98
|
+
|
|
99
|
+
# Declared by a privilege tool, never proven by it: article 6 keeps a
|
|
100
|
+
# delegation visible instead of collapsing it into the human's identity.
|
|
101
|
+
declared = declared_delegation()
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
connection = connect(profile)
|
|
105
|
+
except SocketClientProblem as failure:
|
|
106
|
+
could_not_ask = failure.classification != "refused"
|
|
107
|
+
document = render.envelope(
|
|
108
|
+
CONTRACT_GENERATION,
|
|
109
|
+
render.verification_document(None, None, False),
|
|
110
|
+
problem=failure.problem.to_document(CONTRACT_GENERATION),
|
|
111
|
+
)
|
|
112
|
+
_write(document, arguments.json, stderr, could_not_ask=could_not_ask)
|
|
113
|
+
return exit_codes.EXIT_COULD_NOT_ASK if could_not_ask else exit_codes.EXIT_REFUSED
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
ask = DecisionAsk(
|
|
117
|
+
capability=arguments.capability,
|
|
118
|
+
scope=arguments.scope,
|
|
119
|
+
arguments_digest=arguments.arguments_digest,
|
|
120
|
+
correlation=arguments.correlation,
|
|
121
|
+
)
|
|
122
|
+
# Read before the question is put, because it is already true: the far
|
|
123
|
+
# end was verified before a byte was written, and it stays what was
|
|
124
|
+
# verified whether or not an answer ever comes back.
|
|
125
|
+
verification = render.verification_document(
|
|
126
|
+
connection.server_credential.uid, connection.expected_uid, connection.verified
|
|
127
|
+
)
|
|
128
|
+
# A transport failure with the question already on the wire (a daemon
|
|
129
|
+
# restarted, stopped or killed between the accept and the answer) and
|
|
130
|
+
# an answer that arrived but is not a decision document are both
|
|
131
|
+
# non-answers: nothing is reported as an answer, and neither reaches
|
|
132
|
+
# the shell as a traceback — exit 1 is this client's published code
|
|
133
|
+
# for `deny` (articles 1 and 2). The one rule lives beside the reads.
|
|
134
|
+
result = reads.read(lambda: connection.ask_decision(ask, declared))
|
|
135
|
+
if isinstance(result, Answered):
|
|
136
|
+
document = render.envelope(
|
|
137
|
+
result.contract_generation, verification, result=result.value.to_document()
|
|
138
|
+
)
|
|
139
|
+
_write(document, arguments.json, stdout)
|
|
140
|
+
return _exit_for_answer(result.value.outcome)
|
|
141
|
+
could_not_ask = not isinstance(result, Refused)
|
|
142
|
+
document = render.envelope(
|
|
143
|
+
CONTRACT_GENERATION,
|
|
144
|
+
verification,
|
|
145
|
+
problem=result.problem.to_document(CONTRACT_GENERATION),
|
|
146
|
+
)
|
|
147
|
+
_write(document, arguments.json, stderr, could_not_ask=could_not_ask)
|
|
148
|
+
return exit_codes.EXIT_COULD_NOT_ASK if could_not_ask else exit_codes.EXIT_REFUSED
|
|
149
|
+
finally:
|
|
150
|
+
connection.close()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _write(
|
|
154
|
+
document: dict[str, object], as_json: bool, stream: TextIO, *, could_not_ask: bool = False
|
|
155
|
+
) -> None:
|
|
156
|
+
if as_json:
|
|
157
|
+
render.write_json(document, stream)
|
|
158
|
+
return
|
|
159
|
+
verification = document["verification"]
|
|
160
|
+
assert isinstance(verification, dict)
|
|
161
|
+
render.write_verification(verification, stream)
|
|
162
|
+
result = document.get("result")
|
|
163
|
+
if isinstance(result, dict):
|
|
164
|
+
render.write_decision(result, stream)
|
|
165
|
+
problem = document.get("problem")
|
|
166
|
+
if isinstance(problem, dict):
|
|
167
|
+
render.write_problem(problem, stream, could_not_ask=could_not_ask)
|