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/reads.py ADDED
@@ -0,0 +1,254 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The shared path for scoped reads: verify the connection and render its result.
3
+
4
+ A read acts for nobody and carries no delegation. Its success says a record
5
+ was read, never that the effect recorded there may be exercised (article 1).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from collections.abc import Callable, Mapping
12
+ from typing import Final, Protocol, TextIO
13
+
14
+ from sayfirst_contract.client import Answered, CouldNotAsk, Refused, Result
15
+ from sayfirst_contract.generation import CONTRACT_GENERATION
16
+ from sayfirst_contract.problems import Problem, ProblemCode, problem_retryable
17
+ from sayfirst_contract.transport.socket_client import (
18
+ PER_USER,
19
+ SYSTEM,
20
+ ProfileMisuse,
21
+ SocketClientProblem,
22
+ SocketProfile,
23
+ VerifiedConnection,
24
+ connect,
25
+ )
26
+
27
+ from . import exit_codes, render
28
+
29
+ INPUT_ERRORS: Final = (
30
+ AttributeError,
31
+ KeyError,
32
+ TypeError,
33
+ ValueError,
34
+ OverflowError,
35
+ RecursionError,
36
+ )
37
+ """The exceptions an answer from the daemon can raise on its way into this client.
38
+
39
+ The contract's readers classify what they can and let the rest through: a `200`
40
+ whose body is JSON but not an object raises `AttributeError` inside
41
+ `read_decision`, a reply nested past the interpreter's limit raises
42
+ `RecursionError` inside `json.loads`, a timestamp outside the calendar raises
43
+ `OverflowError`. Every one of them is an answer this client could not read —
44
+ « could not ask », never « denied » (articles 1 and 2) — and an escape from here
45
+ ends the process with a traceback, exit 1, which is this client's code for deny.
46
+ One tuple, because five copies of it is how a sixth caller comes to spell none.
47
+ """
48
+
49
+ DOCUMENT_DEPTH_LIMIT: Final = 64
50
+ """How deeply nested a daemon document may be before this client declines to read it.
51
+
52
+ A real record, page or bundle nests a handful of levels (entries, bodies,
53
+ attached policies). A document nested thousands deep still parses, and then the
54
+ step that renders it back into an envelope exhausts the interpreter and ends the
55
+ process with a traceback — exit 1, deny again. A bound stated here is honest; an
56
+ escape is not.
57
+
58
+ **The bound is measured where the render happens**, which is `finish` below: it
59
+ is the whole answer a command composes, not one page of it, that the envelope is
60
+ built from. `PAGE_DEPTH_LIMIT` is the same rule read one step earlier, so that a
61
+ page a walk accepts is a page the command that walks it can render.
62
+ """
63
+
64
+ PAGE_DEPTH_LIMIT: Final = DOCUMENT_DEPTH_LIMIT - 2
65
+ """How deeply nested ONE page may be, which is two levels shallower, and why.
66
+
67
+ A paging read hands `finish` the pages it collected under a member of its own —
68
+ `{"pages": [page, …]}` — so every page sits three levels down in the answer that
69
+ is actually rendered, and its own deepest member two levels deeper than it
70
+ measured alone. A page validator that used the full bound therefore accepted
71
+ pages the render then refused, after the prose for the pages before it had
72
+ already reached the caller: half an answer and then « could not ask » is worse
73
+ than either answer alone.
74
+
75
+ **One rule, one number, subtracted once, and measured rather than argued.** The
76
+ `2` is not a margin: it is the exact difference between the two documents, and
77
+ `tests/test_evidence_history_and_audit.py`'s bound pair is what pins it — a page
78
+ at the full bound renders as an answer, and one level past it is refused before
79
+ a byte reaches stdout. A subtraction that drifted in either direction turns one
80
+ of those two red. A caller composing a different answer around a page passes its
81
+ own limit to `too_deep`, which is why that argument exists; nothing else is
82
+ bounded twice.
83
+ """
84
+
85
+
86
+ class DocumentValue(Protocol):
87
+ """A typed contract value that supplies its own document."""
88
+
89
+ def to_document(self) -> Mapping[str, object]: ...
90
+
91
+
92
+ def positive(value: str) -> int:
93
+ """A bound a caller supplies: zero and below are misuse, not a small read.
94
+
95
+ One rule across the slice, because `--pages 0` claiming « not found within 0
96
+ page(s) » with exit 0 is a mistyped bound rendered as an established absence.
97
+ """
98
+ number = int(value)
99
+ if number < 1:
100
+ raise argparse.ArgumentTypeError("must be a positive integer")
101
+ return number
102
+
103
+
104
+ def too_deep(value: object, limit: int = DOCUMENT_DEPTH_LIMIT) -> bool:
105
+ """Whether a document nests beyond `limit` — iteratively, so the check itself
106
+ can never be the recursion it guards against.
107
+
108
+ The default is the bound on a whole answer; a caller bounding one piece of
109
+ one passes `PAGE_DEPTH_LIMIT` and says which piece in its own refusal.
110
+ """
111
+ pending: list[tuple[object, int]] = [(value, 1)]
112
+ while pending:
113
+ item, depth = pending.pop()
114
+ if depth > limit:
115
+ return True
116
+ if isinstance(item, Mapping):
117
+ pending.extend((child, depth + 1) for child in item.values())
118
+ elif isinstance(item, list):
119
+ pending.extend((child, depth + 1) for child in item)
120
+ return False
121
+
122
+
123
+ def unreadable(failure: Exception) -> CouldNotAsk:
124
+ """An answer this client could not read, said as that and never as a refusal."""
125
+ return CouldNotAsk(
126
+ # Retryability is the registry's to state, never this client's: the
127
+ # contract's table says « not stated » for an unreadable answer, and the
128
+ # transport says the same when it is the one that classifies (article 2).
129
+ Problem(
130
+ ProblemCode.ANSWER_UNREADABLE,
131
+ str(failure),
132
+ problem_retryable(ProblemCode.ANSWER_UNREADABLE),
133
+ CONTRACT_GENERATION,
134
+ )
135
+ )
136
+
137
+
138
+ def read[T](operation: Callable[[], Result[T]]) -> Result[T]:
139
+ """Run one read of the daemon: the two ways an answer fails to be one, in one place.
140
+
141
+ A transport failure carries the contract's own classification — refused, or
142
+ could not ask. An answer that arrives and cannot be read is
143
+ `answer_unreadable`. Neither is a decision, and neither may reach the shell
144
+ as exit 1, the code for « deny ». Every read of this client goes through
145
+ here, a page walk included, so that a command cannot be the one that forgot.
146
+ """
147
+ try:
148
+ return operation()
149
+ except SocketClientProblem as failure:
150
+ return connection_problem(failure)
151
+ except INPUT_ERRORS as failure:
152
+ return unreadable(failure)
153
+
154
+
155
+ def add_connection_arguments(parser: argparse.ArgumentParser) -> None:
156
+ """Every read names its scope explicitly, with the writer's connection options."""
157
+ parser.add_argument("--scope", required=True, help="the scope the question is asked in")
158
+ parser.add_argument("--socket", required=True, help="the path of the daemon's socket")
159
+ parser.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER)
160
+ parser.add_argument(
161
+ "--daemon-user",
162
+ default=None,
163
+ help="the account the daemon runs as; a system profile names it",
164
+ )
165
+ parser.add_argument("--json", action="store_true", help="write the envelope instead of prose")
166
+
167
+
168
+ def connection_problem(failure: SocketClientProblem) -> Refused | CouldNotAsk:
169
+ """A transport exception is a non-answer, with the contract's classification."""
170
+ if failure.classification == "refused":
171
+ return Refused(failure.problem)
172
+ return CouldNotAsk(failure.problem)
173
+
174
+
175
+ def open_connection(arguments: argparse.Namespace, stderr: TextIO) -> VerifiedConnection | int:
176
+ """Verify the daemon, or report why this invocation could not open a connection."""
177
+ try:
178
+ profile = SocketProfile(
179
+ arguments.socket,
180
+ mode=arguments.mode,
181
+ daemon_user=arguments.daemon_user,
182
+ scope=arguments.scope,
183
+ )
184
+ except ProfileMisuse as misuse:
185
+ stderr.write(f"{misuse}\n")
186
+ return exit_codes.EXIT_MISUSE
187
+ try:
188
+ return connect(profile)
189
+ except SocketClientProblem as failure:
190
+ return _write_problem(
191
+ connection_problem(failure),
192
+ arguments,
193
+ render.verification_document(None, None, False),
194
+ stderr,
195
+ )
196
+
197
+
198
+ def finish(
199
+ result: Result[DocumentValue | Mapping[str, object]],
200
+ arguments: argparse.Namespace,
201
+ connection: VerifiedConnection,
202
+ stdout: TextIO,
203
+ stderr: TextIO,
204
+ render_answer: Callable[[Mapping[str, object], TextIO], None],
205
+ ) -> int:
206
+ """Render the supplied record or problem; a record's outcome is only data."""
207
+ verification = render.verification_document(
208
+ connection.server_credential.uid, connection.expected_uid, connection.verified
209
+ )
210
+ # The document is materialised and bounded through the same helper the read
211
+ # used: `to_document` is itself a step an unreadable answer raises inside,
212
+ # and the render below is the step a document nested past the bound would
213
+ # overflow. Either way the answer is one this client could not read.
214
+ answer = read(lambda: _bounded(result)) if isinstance(result, Answered) else result
215
+ if isinstance(answer, Answered):
216
+ document = answer.value
217
+ if arguments.json:
218
+ render.write_json(
219
+ render.envelope(CONTRACT_GENERATION, verification, result=document), stdout
220
+ )
221
+ else:
222
+ render_answer(document, stdout)
223
+ return 0
224
+ return _write_problem(answer, arguments, verification, stderr)
225
+
226
+
227
+ def _bounded(
228
+ result: Answered[DocumentValue | Mapping[str, object]],
229
+ ) -> Answered[Mapping[str, object]]:
230
+ """The answer as the document about to be rendered, refused if it nests too deep."""
231
+ value = result.value
232
+ document = value if isinstance(value, Mapping) else value.to_document()
233
+ if too_deep(document):
234
+ raise ValueError(
235
+ f"nested deeper than {DOCUMENT_DEPTH_LIMIT} levels; not a document this client reads"
236
+ )
237
+ return Answered(document, result.contract_generation)
238
+
239
+
240
+ def _write_problem(
241
+ result: Refused | CouldNotAsk,
242
+ arguments: argparse.Namespace,
243
+ verification: Mapping[str, object],
244
+ stderr: TextIO,
245
+ ) -> int:
246
+ could_not_ask = not isinstance(result, Refused)
247
+ problem = result.problem.to_document(CONTRACT_GENERATION)
248
+ document = render.envelope(CONTRACT_GENERATION, verification, problem=problem)
249
+ if arguments.json:
250
+ render.write_json(document, stderr)
251
+ else:
252
+ render.write_verification(verification, stderr)
253
+ render.write_problem(problem, stderr, could_not_ask=could_not_ask)
254
+ return exit_codes.EXIT_COULD_NOT_ASK if could_not_ask else exit_codes.EXIT_REFUSED
sayfirst_cli/render.py ADDED
@@ -0,0 +1,138 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """How an answer reaches a person, and what this client is not allowed to add.
3
+
4
+ Article 1: a client explains and invokes control semantics; it never derives an
5
+ answer the control plane did not give. So every member of the envelope below is
6
+ either something the control plane said, something the operating system said, or
7
+ something this process did — and each is labelled as which. Nothing is computed
8
+ from an answer and presented beside it as though it were part of it.
9
+
10
+ Article 2: an absence is never rendered as a negative fact, a zero or a healthy
11
+ state. Where the answer carries no policy version, no approval reference and no
12
+ reason this generation knows, the rendering says so in words rather than leaving
13
+ a blank the reader completes with a guess.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from collections.abc import Mapping
20
+ from typing import TextIO
21
+
22
+ #: What is written where the control plane said nothing. It is a sentence and
23
+ #: not an empty string, because a blank field reads as a value (article 2).
24
+ NOT_STATED = "not stated"
25
+
26
+
27
+ def verification_document(
28
+ server_uid: int | None, expected_uid: int | None, verified: bool
29
+ ) -> dict[str, object]:
30
+ """What this process verified about the far end before it wrote a byte.
31
+
32
+ This is the one part of the envelope the control plane did not say: the
33
+ client read the server's peer credential and compared it (article 6). It is
34
+ kept in a member of its own so that no reader can mistake it for something
35
+ the daemon claimed about itself.
36
+ """
37
+ return {"server_uid": server_uid, "expected": expected_uid, "verified": verified}
38
+
39
+
40
+ def envelope(
41
+ contract_generation: int,
42
+ verification: Mapping[str, object],
43
+ *,
44
+ result: Mapping[str, object] | None = None,
45
+ problem: Mapping[str, object] | None = None,
46
+ ) -> dict[str, object]:
47
+ """The machine-readable form: the answer, or the problem, and never both."""
48
+ document: dict[str, object] = {
49
+ "contract_generation": contract_generation,
50
+ "verification": dict(verification),
51
+ }
52
+ if result is not None:
53
+ document["result"] = dict(result)
54
+ if problem is not None:
55
+ document["problem"] = dict(problem)
56
+ return document
57
+
58
+
59
+ def write_json(document: Mapping[str, object], stream: TextIO) -> None:
60
+ """The whole envelope, stable under sorting so a diff of two runs is readable."""
61
+ stream.write(json.dumps(document, indent=2, sort_keys=True))
62
+ stream.write("\n")
63
+
64
+
65
+ def _stated(value: object) -> str:
66
+ """A member as a person reads it, with absence said rather than shown."""
67
+ return NOT_STATED if value is None else str(value)
68
+
69
+
70
+ def write_decision(document: Mapping[str, object], stream: TextIO) -> None:
71
+ """A decision, in the words the control plane used for it.
72
+
73
+ The outcome is written as the control plane spelled it. This client holds no
74
+ table from `allow` to "approved" or from `deny` to "blocked": a second
75
+ vocabulary for the same three outcomes is a second control plane, without
76
+ the evidence (articles 1 and 4).
77
+ """
78
+ stream.write(f"outcome: {document['outcome']}\n")
79
+ stream.write(f"reason: {_stated(document.get('reason'))}\n")
80
+ stream.write(f"capability: {document['capability']} in scope {document['scope']}\n")
81
+ stream.write(f"decision: {document['decision_ref']} at {document['decided_at']}\n")
82
+ stream.write(f"policy version: {_stated(document.get('policy_version'))}\n")
83
+ if document.get("approval_ref") is not None:
84
+ stream.write(f"approval: {document['approval_ref']}\n")
85
+
86
+
87
+ def write_verification(document: Mapping[str, object], stream: TextIO) -> None:
88
+ """What was verified about the far end, in the same words `whoami` uses."""
89
+ stream.write(
90
+ f"verified: {str(document['verified']).lower()} "
91
+ f"(server_uid {_stated(document['server_uid'])}, "
92
+ f"expected {_stated(document['expected'])})\n"
93
+ )
94
+
95
+
96
+ def write_record(document: Mapping[str, object], stream: TextIO) -> None:
97
+ """Every supplied record member, with its reason and rule on their own lines."""
98
+ order = (
99
+ "decision_ref",
100
+ "scope",
101
+ "capability",
102
+ "outcome",
103
+ "reason",
104
+ "rule_id",
105
+ "policy_version",
106
+ "decided_at",
107
+ "correlation",
108
+ "grant_id",
109
+ "approval_ref",
110
+ )
111
+ for key in (*order, *sorted(document.keys() - set(order))):
112
+ if key in document:
113
+ stream.write(f"{key}: {_stated(document[key])}\n")
114
+
115
+
116
+ def write_chain_position(position: Mapping[str, object], stream: TextIO) -> None:
117
+ """The position and grade the plane supplied, or the bound actually read."""
118
+ if position.get("found") is False:
119
+ stream.write(f"chain: not found within {position['pages']} page(s)\n")
120
+ else:
121
+ stream.write(
122
+ f"chain: sequence {position['sequence']}, entry_hash {position['entry_hash']}, "
123
+ f"grade {position['grade']}\n"
124
+ )
125
+
126
+
127
+ def write_problem(document: Mapping[str, object], stream: TextIO, *, could_not_ask: bool) -> None:
128
+ """A refusal or an unanswerable question, each said as what it is.
129
+
130
+ Article 1: "could not ask" is never written as "denied" or as "allowed", and
131
+ article 2 adds that it is not a negative fact either. The line therefore
132
+ names which of the two happened before it names the code, because the code
133
+ alone does not tell a reader whether an answer exists somewhere.
134
+ """
135
+ kind = "could not ask" if could_not_ask else "refused"
136
+ stream.write(f"{kind}: {document['code']}: {document['message']}\n")
137
+ retryable = document.get("retryable")
138
+ stream.write(f"retryable: {NOT_STATED if retryable is None else str(retryable).lower()}\n")
sayfirst_cli/trace.py ADDED
@@ -0,0 +1,88 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Read a decision and locate its first effect entry within a bounded evidence read."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from collections.abc import Mapping, Sequence
9
+ from typing import TextIO
10
+
11
+ from sayfirst_contract.client import Answered, Result
12
+ from sayfirst_contract.generation import CONTRACT_GENERATION
13
+
14
+ from . import pages, reads, render
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="sayfirst trace", description="Read a decision and its position in scoped evidence."
20
+ )
21
+ reads.add_connection_arguments(parser)
22
+ parser.add_argument("--decision", required=True, help="the reference of the recorded decision")
23
+ parser.add_argument(
24
+ "--pages", type=reads.positive, default=10, help="the maximum number of evidence pages"
25
+ )
26
+ return parser
27
+
28
+
29
+ def _write_trace(document: Mapping[str, object], stream: TextIO) -> None:
30
+ render.write_record({key: value for key, value in document.items() if key != "chain"}, stream)
31
+ render.write_chain_position(document["chain"], stream)
32
+
33
+
34
+ def main(
35
+ argv: Sequence[str] | None = None, *, out: TextIO | None = None, err: TextIO | None = None
36
+ ) -> int:
37
+ stdout, stderr = out or sys.stdout, err or sys.stderr
38
+ arguments = build_parser().parse_args(argv)
39
+ connection = reads.open_connection(arguments, stderr)
40
+ if isinstance(connection, int):
41
+ return connection
42
+
43
+ def walk() -> Result[Mapping[str, object]]:
44
+ """The record, then the pages, then where in the chain the decision sits."""
45
+ result = connection.read_decision(arguments.scope, arguments.decision)
46
+ if not isinstance(result, Answered):
47
+ return result
48
+ record = result.value.to_document()
49
+ from_sequence, read_pages, position = 1, 0, None
50
+ for _ in range(arguments.pages):
51
+ # The daemon closes the connection after a decision read (an adapter
52
+ # wrote that answer), and an evidence read may close its own. The
53
+ # transport never re-opens an address on a caller's behalf (rule
54
+ # C4), so every page is read on a connection reopened — and the far
55
+ # end verified again — here, exactly as `history` walks its pages.
56
+ connection.reconnect()
57
+ page = connection.read_evidence(arguments.scope, from_sequence, 100)
58
+ if not isinstance(page, Answered):
59
+ return page
60
+ read_pages += 1
61
+ entries, verification, next_from = pages.members(page.value, from_sequence)
62
+ for entry in entries:
63
+ if entry["kind"] == "effect" and entry["body"]["decision_id"] == arguments.decision:
64
+ grade = next(
65
+ (
66
+ item["grade"]
67
+ for item in verification["grades"]
68
+ if item["connection_id"] == entry["connection_id"]
69
+ ),
70
+ "unknown",
71
+ )
72
+ position = {
73
+ "sequence": entry["sequence"],
74
+ "entry_hash": entry["entry_hash"],
75
+ "grade": grade,
76
+ }
77
+ break
78
+ if position is not None or next_from is None:
79
+ break
80
+ from_sequence = next_from
81
+ if position is None:
82
+ position = {"found": False, "pages": read_pages}
83
+ return Answered({**record, "chain": position}, CONTRACT_GENERATION)
84
+
85
+ try:
86
+ return reads.finish(reads.read(walk), arguments, connection, stdout, stderr, _write_trace)
87
+ finally:
88
+ connection.close()