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.
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The product command-line interface of the `sayfirst` control plane."""
@@ -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)