agent-postmortem 0.3.4__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.
- agent_postmortem/__init__.py +9 -0
- agent_postmortem/argerrors.py +160 -0
- agent_postmortem/claims.py +43 -0
- agent_postmortem/cli.py +2278 -0
- agent_postmortem/commands.py +1543 -0
- agent_postmortem/config.py +435 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
- agent_postmortem/detectors/__init__.py +98 -0
- agent_postmortem/detectors/attempt_failed.py +261 -0
- agent_postmortem/detectors/base.py +319 -0
- agent_postmortem/detectors/bash_backgrounded.py +72 -0
- agent_postmortem/detectors/bash_timeout.py +94 -0
- agent_postmortem/detectors/claim_contradiction.py +251 -0
- agent_postmortem/detectors/classifier_unavailable.py +73 -0
- agent_postmortem/detectors/codex_unavailable.py +102 -0
- agent_postmortem/detectors/codex_write_denied.py +100 -0
- agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
- agent_postmortem/detectors/mislabeled_kill.py +164 -0
- agent_postmortem/detectors/missing_artifact.py +159 -0
- agent_postmortem/detectors/permission_denied.py +99 -0
- agent_postmortem/detectors/silent_retry.py +163 -0
- agent_postmortem/detectors/structured_output_failure.py +91 -0
- agent_postmortem/detectors/tool_json_error.py +77 -0
- agent_postmortem/detectors/wasted_time.py +125 -0
- agent_postmortem/detectors/workflow_failed.py +62 -0
- agent_postmortem/disclosure.py +931 -0
- agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
- agent_postmortem/hooks_cli.py +1269 -0
- agent_postmortem/ledger.py +624 -0
- agent_postmortem/markers.py +505 -0
- agent_postmortem/models.py +815 -0
- agent_postmortem/observe.py +792 -0
- agent_postmortem/reader.py +4611 -0
- agent_postmortem/redact.py +1981 -0
- agent_postmortem/report.py +3010 -0
- agent_postmortem/rescue.py +2096 -0
- agent_postmortem/returns.py +499 -0
- agent_postmortem/rules.py +627 -0
- agent_postmortem/severity.py +275 -0
- agent_postmortem/state.py +1261 -0
- agent_postmortem/trend.py +391 -0
- agent_postmortem/watch.py +973 -0
- agent_postmortem-0.3.4.dist-info/METADATA +264 -0
- agent_postmortem-0.3.4.dist-info/RECORD +56 -0
- agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
- agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
- agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
"""Disclosure levels and stable aliases (docs/DESIGN.md 4.13.5, 4.13.11's
|
|
2
|
+
"N3, disclosure" items, 7.5). N3a only: pure logic plus the orchestration
|
|
3
|
+
that drives agent_postmortem.state's disclosure I/O primitives. Nothing
|
|
4
|
+
here is wired into a renderer, a CLI flag, a hook, or `watch` yet -- that
|
|
5
|
+
is N3b. No command's output changes because this module exists.
|
|
6
|
+
|
|
7
|
+
LEVELS names the three disclosure levels (share/local/raw); the alias
|
|
8
|
+
kinds are agent, session, project. An AliasBook, returned by register(),
|
|
9
|
+
is the one object a caller holds for the lifetime of one command: it was
|
|
10
|
+
handed every identity the command already knows it will render, assigned
|
|
11
|
+
each of them a stable alias under one lock hold (docs/DESIGN.md 7.5/P9),
|
|
12
|
+
and from then on answers alias(kind, real_id) from memory. A registration
|
|
13
|
+
that cannot complete for any reason (secret or registry unavailable, lock
|
|
14
|
+
contention, a failed write, a bound that would be exceeded) returns a
|
|
15
|
+
fallback book instead: positional agent-N/session-N/project-N per kind, in
|
|
16
|
+
first-use order, available=False. A pre-commit failure leaves the real
|
|
17
|
+
secret and registry exactly as they were, byte-for-byte and mtime
|
|
18
|
+
untouched; a post-commit failure (docs/DESIGN.md 7.5a) is the one
|
|
19
|
+
exception -- the new file may already be installed, and this fallback is
|
|
20
|
+
what register() returns only once re-probing it and requiring a fresh
|
|
21
|
+
directory-durability confirmation have both still failed to confirm it,
|
|
22
|
+
never by silently claiming nothing changed. A fallback book never
|
|
23
|
+
contains a real id, by construction -- it is never handed one.
|
|
24
|
+
|
|
25
|
+
Every actual file read or write happens in agent_postmortem.state, never
|
|
26
|
+
here (docs/DESIGN.md 5.4, tests/test_no_write_surface.py): this module
|
|
27
|
+
calls state.lock_state_dir(), state.probe_secret(), state.create_secret(),
|
|
28
|
+
state.probe_registry_bytes(), and state.write_registry_bytes(), and
|
|
29
|
+
decides, in memory, what those calls should do and what they returned.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import hmac
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import uuid as uuid_module
|
|
40
|
+
from collections.abc import Callable
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
from agent_postmortem import state
|
|
45
|
+
|
|
46
|
+
LEVELS: tuple[str, ...] = ("share", "local", "raw")
|
|
47
|
+
|
|
48
|
+
# N3b P15: the very first line of text output at local and raw. Share
|
|
49
|
+
# prints no such line, and JSON never does.
|
|
50
|
+
FIRST_LINE: dict[str, str] = {
|
|
51
|
+
"local": "disclosure: local (real names and paths; do not paste)",
|
|
52
|
+
"raw": "disclosure: raw (real names, absolute paths; do not paste)",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
KINDS: tuple[str, ...] = ("agent", "session", "project")
|
|
56
|
+
|
|
57
|
+
# docs/DESIGN.md 7.5/P4: both bounds are module constants a test can
|
|
58
|
+
# override (monkeypatch.setattr(disclosure, "MAX_ENTRIES", ...)) to exercise
|
|
59
|
+
# the fallback without actually building a 4 MiB or 50,000-entry file.
|
|
60
|
+
MAX_ENTRIES = 50_000
|
|
61
|
+
MAX_BYTES = 4 * 1024 * 1024 # 4 MiB
|
|
62
|
+
|
|
63
|
+
MIN_ALIAS_LEN = 12
|
|
64
|
+
MAX_ALIAS_LEN = 64
|
|
65
|
+
|
|
66
|
+
# N3b prints this; N3a only defines it, so there is exactly one spelling of
|
|
67
|
+
# the diagnostic for every future caller to share (docs/DESIGN.md 7.5/P8).
|
|
68
|
+
UNAVAILABLE_DIAGNOSTIC = "stable aliases unavailable"
|
|
69
|
+
|
|
70
|
+
_REGISTRY_VERSION = 1
|
|
71
|
+
_REGISTRY_TOP_KEYS = frozenset({"version", "agent", "session", "project"})
|
|
72
|
+
|
|
73
|
+
# docs/DESIGN.md 5.1: an agent id is 17 lowercase hex characters (the
|
|
74
|
+
# existing reader._AGENT_ID_RE additionally requires the first to be "a";
|
|
75
|
+
# this plan's P2 only requires "17 lowercase hex", the broader of the two,
|
|
76
|
+
# so every id reader.py accepts is also canonical here).
|
|
77
|
+
_AGENT_ID_RE = re.compile(r"[0-9a-f]{17}")
|
|
78
|
+
_HEX64_RE = re.compile(r"[0-9a-f]{64}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class DisclosureContext:
|
|
83
|
+
"""N3b D-a: the one object a command builds once from its parsed level
|
|
84
|
+
and hands to its Redactor. `level` is one of LEVELS. `book` is the
|
|
85
|
+
AliasBook the command registered its identities with, or None for a
|
|
86
|
+
command that renders no alias (every command in Part 1a, and a versioned
|
|
87
|
+
JSON command always, D-k). `home` is the home directory structural
|
|
88
|
+
redaction replaces with "~". `repo_root` is the run's repository root
|
|
89
|
+
when an observation the command already made resolved one (D-n), else
|
|
90
|
+
None. `identities` is what the command collected for its one
|
|
91
|
+
registration. Nothing here reads the filesystem except Path.home() for
|
|
92
|
+
the default home."""
|
|
93
|
+
|
|
94
|
+
level: str
|
|
95
|
+
book: "AliasBook | None" = None
|
|
96
|
+
home: Path = field(default_factory=Path.home)
|
|
97
|
+
repo_root: str | None = None
|
|
98
|
+
# N3b P9/P11: every (kind, real_id) the command collected before it
|
|
99
|
+
# rendered (the same list it registered with the book). A free-text
|
|
100
|
+
# sink at share substitutes these by their alias, the run's project slug
|
|
101
|
+
# before structural redaction.
|
|
102
|
+
identities: tuple[tuple[str, str], ...] = ()
|
|
103
|
+
|
|
104
|
+
def __post_init__(self) -> None:
|
|
105
|
+
if not isinstance(self.level, str) or self.level not in LEVELS:
|
|
106
|
+
raise ValueError(f"unknown disclosure level: {self.level!r}")
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def enabled(self) -> bool:
|
|
110
|
+
"""Structural redaction is on at every level but raw."""
|
|
111
|
+
return self.level != "raw"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --- rendered forms (P10) -----------------------------------------------------
|
|
115
|
+
|
|
116
|
+
_POSITIONAL_ALIAS_RE = re.compile(r"(?:agent|session|project)-[0-9]+")
|
|
117
|
+
_FORM_PREFIX = {"agent": "a:", "session": "s:", "project": "project:"}
|
|
118
|
+
_FILE_STEM_PREFIX = {"agent": "agent-", "session": "session-"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def is_positional_alias(alias: str) -> bool:
|
|
122
|
+
"""A fallback book's name (agent-3, session-1, project-2). A stable
|
|
123
|
+
alias is lowercase hex, so the two never collide."""
|
|
124
|
+
return _POSITIONAL_ALIAS_RE.fullmatch(alias) is not None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def rendered_form(kind: str, alias: str) -> str:
|
|
128
|
+
"""P10: the text form of an identity: a:<alias>, s:<alias>,
|
|
129
|
+
project:<alias>. A positional name is already its own form."""
|
|
130
|
+
if is_positional_alias(alias):
|
|
131
|
+
return alias
|
|
132
|
+
return _FORM_PREFIX[kind] + alias
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def file_stem(kind: str, alias: str) -> str:
|
|
136
|
+
"""P10: the file-name stem of an agent or session identity in an
|
|
137
|
+
evidence pointer: agent-<alias>, session-<alias> (bare hex, no "a:"). A
|
|
138
|
+
positional name is its own stem (agent-3, session-1)."""
|
|
139
|
+
if is_positional_alias(alias):
|
|
140
|
+
return alias
|
|
141
|
+
return _FILE_STEM_PREFIX[kind] + alias
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _under(root: str, path: str) -> bool:
|
|
145
|
+
"""Whether `path` is `root` or lies below it, by spelling: both must be
|
|
146
|
+
absolute and already normalized. A ValueError from commonpath (mixed
|
|
147
|
+
drives, or one relative path) means outside."""
|
|
148
|
+
try:
|
|
149
|
+
return os.path.commonpath([root, path]) == root
|
|
150
|
+
except ValueError:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_SCHEME_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def local_path(
|
|
158
|
+
value: object,
|
|
159
|
+
*,
|
|
160
|
+
repo_root: str | None,
|
|
161
|
+
home: Path | str | None,
|
|
162
|
+
base: str | None,
|
|
163
|
+
outside: Callable[[str], str],
|
|
164
|
+
) -> str:
|
|
165
|
+
"""N3b P19 (D-m): the one function every sink uses to show a path at
|
|
166
|
+
`local`; never returns an absolute path.
|
|
167
|
+
|
|
168
|
+
The path is made absolute against `base` (the documented base, for
|
|
169
|
+
example the run's cwd for a relative transcript path) when it is
|
|
170
|
+
relative, then os.path.normpath-ed; symlinks are never resolved, so a
|
|
171
|
+
path counts by how it is spelled. Inside the repository
|
|
172
|
+
(os.path.commonpath([repo_root, path]) == repo_root) it is
|
|
173
|
+
repo-relative ("." for the root itself); inside home but outside the
|
|
174
|
+
repository it is the structural "~/.../<basename>"; outside both it is
|
|
175
|
+
`outside(value)`, the caller's path-N pseudonym. `repo_root` comes only
|
|
176
|
+
from an observation the command already made (D-n); None, or a root
|
|
177
|
+
that is not absolute, means every path counts as outside the repository.
|
|
178
|
+
A relative path with no absolute base cannot be placed and counts as
|
|
179
|
+
outside. A value that is not a non-empty string without NUL is passed
|
|
180
|
+
to `outside` as is.
|
|
181
|
+
"""
|
|
182
|
+
if not isinstance(value, str) or not value or "\x00" in value:
|
|
183
|
+
return outside(value) if isinstance(value, str) else outside(str(value))
|
|
184
|
+
if _SCHEME_RE.match(value):
|
|
185
|
+
# S11: a URL or other `scheme:` spelling is never a place on disk.
|
|
186
|
+
return outside(value)
|
|
187
|
+
if value == "~" or value.startswith("~/"):
|
|
188
|
+
# S11: `~/` is expanded against the context's home, then the rule.
|
|
189
|
+
home_text = os.path.normpath(str(home)) if home is not None else ""
|
|
190
|
+
if not (home_text and os.path.isabs(home_text)):
|
|
191
|
+
return outside(value)
|
|
192
|
+
value = home_text + value[1:]
|
|
193
|
+
elif value.startswith("//"):
|
|
194
|
+
# S11: POSIX normpath keeps exactly two leading slashes; they name the
|
|
195
|
+
# same place as one, so collapse them before the containment check.
|
|
196
|
+
value = "/" + value.lstrip("/")
|
|
197
|
+
if os.path.isabs(value):
|
|
198
|
+
candidate = value
|
|
199
|
+
elif base and os.path.isabs(base):
|
|
200
|
+
candidate = os.path.join(base, value)
|
|
201
|
+
else:
|
|
202
|
+
return outside(value)
|
|
203
|
+
normalized = os.path.normpath(candidate)
|
|
204
|
+
|
|
205
|
+
if repo_root and os.path.isabs(repo_root):
|
|
206
|
+
root = os.path.normpath(repo_root)
|
|
207
|
+
if _under(root, normalized):
|
|
208
|
+
relative = normalized[len(root):].lstrip(os.sep)
|
|
209
|
+
return relative or "."
|
|
210
|
+
|
|
211
|
+
home_str = os.path.normpath(str(home)) if home is not None else ""
|
|
212
|
+
if home_str and os.path.isabs(home_str) and home_str != os.sep and _under(home_str, normalized):
|
|
213
|
+
if normalized == home_str:
|
|
214
|
+
return "~"
|
|
215
|
+
basename = os.path.basename(normalized)
|
|
216
|
+
return f"~/.../{basename}" if basename else "~"
|
|
217
|
+
|
|
218
|
+
return outside(value)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class DisclosureError(Exception):
|
|
222
|
+
"""Base class for disclosure.py's own errors (not state.py's -- a
|
|
223
|
+
StateError/StateLocked/OSError from the I/O layer is caught by
|
|
224
|
+
register()/AliasBook.alias() and turned into a fallback, never raised
|
|
225
|
+
to the caller)."""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --- canonical id checks (P2) ------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def is_canonical_id(kind: str, value: object) -> bool:
|
|
232
|
+
"""P2's exact shape for each kind. agent: 17 lowercase hex. session: a
|
|
233
|
+
UUID, canonical lowercase-hyphenated form only (str(uuid.UUID(value))
|
|
234
|
+
must round-trip to the same string, so "AAAA...", a braced form, or a
|
|
235
|
+
32-hex-no-dashes spelling are all rejected even though uuid.UUID() would
|
|
236
|
+
parse them). project: a non-empty string with no NUL and no newline
|
|
237
|
+
(docs/DESIGN.md: "the same string `local` prints")."""
|
|
238
|
+
if kind == "agent":
|
|
239
|
+
return isinstance(value, str) and bool(_AGENT_ID_RE.fullmatch(value))
|
|
240
|
+
if kind == "session":
|
|
241
|
+
if not isinstance(value, str):
|
|
242
|
+
return False
|
|
243
|
+
try:
|
|
244
|
+
parsed = uuid_module.UUID(value)
|
|
245
|
+
except (ValueError, AttributeError, TypeError):
|
|
246
|
+
return False
|
|
247
|
+
return str(parsed) == value
|
|
248
|
+
if kind == "project":
|
|
249
|
+
return isinstance(value, str) and value != "" and "\x00" not in value and "\n" not in value
|
|
250
|
+
raise ValueError(f"unknown alias kind: {kind!r}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def require_canonical_id(kind: str, value: object) -> str:
|
|
254
|
+
"""is_canonical_id's gate, raising ValueError on failure: "non-canonical
|
|
255
|
+
ids raise ValueError at registration" (the renderer decides positional
|
|
256
|
+
names for them in N3b; N3a never silently accepts one)."""
|
|
257
|
+
if not is_canonical_id(kind, value):
|
|
258
|
+
raise ValueError(f"not a canonical {kind} id: {value!r}")
|
|
259
|
+
return value # type: ignore[return-value]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# --- HMAC (P1) ----------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def hmac_hex(secret: bytes, real_id: str) -> str:
|
|
266
|
+
"""HMAC-SHA256 of real_id's UTF-8 bytes under secret, lowercase 64 hex
|
|
267
|
+
(hexdigest() is already lowercase). A module-level function, called by
|
|
268
|
+
bare name from _assign_all below, so a test can monkeypatch
|
|
269
|
+
disclosure.hmac_hex to force a chosen collision without needing to
|
|
270
|
+
brute-force one against a real secret."""
|
|
271
|
+
return hmac.new(secret, real_id.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# --- registry schema (P4) ------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def empty_registry() -> dict:
|
|
278
|
+
return {"version": _REGISTRY_VERSION, "agent": {}, "session": {}, "project": {}}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def is_empty_registry(registry: dict) -> bool:
|
|
282
|
+
return all(not registry[kind] for kind in KINDS)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def count_entries(registry: dict) -> int:
|
|
286
|
+
return sum(len(registry[kind]) for kind in KINDS)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def validate_registry(data: object) -> dict | None:
|
|
290
|
+
"""P4's exact schema, validated with nothing coerced:
|
|
291
|
+
{"version": 1, "agent": {...}, "session": {...}, "project": {...}},
|
|
292
|
+
no other top-level key; each table a dict mapping a lowercase 64-hex
|
|
293
|
+
key to an int -- never a bool, which is a subclass of int in Python, so
|
|
294
|
+
the bool check must run before the int check -- in 12..64; per kind,
|
|
295
|
+
the assigned aliases (key[:length]) are unique. Returns a validated
|
|
296
|
+
dict (plain dicts, independent of whatever container type the input
|
|
297
|
+
used) on success, or None for any violation at all: a malformed
|
|
298
|
+
top-level shape, a malformed key, a malformed length, or a duplicate
|
|
299
|
+
alias. MAX_ENTRIES/MAX_BYTES are checked by the caller against the
|
|
300
|
+
registry as a whole (they depend on what is about to be *added*, which
|
|
301
|
+
this function does not know about), not here.
|
|
302
|
+
"""
|
|
303
|
+
if not isinstance(data, dict):
|
|
304
|
+
return None
|
|
305
|
+
# N3a fix round 2, Finding 6: a plain `!= 1` comparison accepts both
|
|
306
|
+
# `True` (bool is an int subclass in Python, and True == 1) and `1.0`
|
|
307
|
+
# (float equality) as version 1. isinstance(x, bool) must be checked
|
|
308
|
+
# before the int check, exactly as for an alias length below.
|
|
309
|
+
version = data.get("version")
|
|
310
|
+
if isinstance(version, bool) or not isinstance(version, int) or version != _REGISTRY_VERSION:
|
|
311
|
+
return None
|
|
312
|
+
if set(data.keys()) != _REGISTRY_TOP_KEYS:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
result: dict = {"version": _REGISTRY_VERSION}
|
|
316
|
+
for kind in KINDS:
|
|
317
|
+
table = data[kind]
|
|
318
|
+
if not isinstance(table, dict):
|
|
319
|
+
return None
|
|
320
|
+
seen_aliases: set[str] = set()
|
|
321
|
+
validated_table: dict[str, int] = {}
|
|
322
|
+
for key, length in table.items():
|
|
323
|
+
if not isinstance(key, str) or not _HEX64_RE.fullmatch(key):
|
|
324
|
+
return None
|
|
325
|
+
if isinstance(length, bool) or not isinstance(length, int):
|
|
326
|
+
return None
|
|
327
|
+
if not (MIN_ALIAS_LEN <= length <= MAX_ALIAS_LEN):
|
|
328
|
+
return None
|
|
329
|
+
alias = key[:length]
|
|
330
|
+
if alias in seen_aliases:
|
|
331
|
+
return None
|
|
332
|
+
seen_aliases.add(alias)
|
|
333
|
+
validated_table[key] = length
|
|
334
|
+
result[kind] = validated_table
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _dumps(registry: dict) -> bytes:
|
|
339
|
+
"""Canonical encoding of a registry dict: stable key order (sort_keys)
|
|
340
|
+
so two writers who agree on content produce byte-identical files, and a
|
|
341
|
+
trailing newline, matching state.py's own state.json convention."""
|
|
342
|
+
return json.dumps(registry, indent=2, sort_keys=True).encode("utf-8") + b"\n"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# --- assignment (P3) -----------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def assign_alias(table: dict[str, int], hashed: str) -> int:
|
|
349
|
+
"""The smallest n in MIN_ALIAS_LEN..MAX_ALIAS_LEN such that hashed[:n]
|
|
350
|
+
is not already the assigned alias (key[:length]) of another entry in
|
|
351
|
+
table. Does not mutate table; the caller decides whether and when to
|
|
352
|
+
record the assignment, so a sequence of calls across several identities
|
|
353
|
+
of a batch can see each other's just-assigned aliases by mutating the
|
|
354
|
+
table in between (P3: "deterministic given registration order")."""
|
|
355
|
+
existing_aliases = {key[:length] for key, length in table.items()}
|
|
356
|
+
for n in range(MIN_ALIAS_LEN, MAX_ALIAS_LEN + 1):
|
|
357
|
+
if hashed[:n] not in existing_aliases:
|
|
358
|
+
return n
|
|
359
|
+
raise DisclosureError(
|
|
360
|
+
f"exhausted alias length space ({MIN_ALIAS_LEN}..{MAX_ALIAS_LEN}) for one hash"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _assign_all(
|
|
365
|
+
registry: dict, secret: bytes, identities: list[tuple[str, str]]
|
|
366
|
+
) -> tuple[dict, dict[tuple[str, str], str], bool]:
|
|
367
|
+
"""Apply P3 to every (kind, real_id) in identities, in order, against a
|
|
368
|
+
deep-enough copy of registry (new dicts per kind table, so the caller's
|
|
369
|
+
own registry argument is never mutated in place). Returns (the possibly
|
|
370
|
+
updated registry, {(kind, real_id): alias} for every identity in
|
|
371
|
+
identities, whether anything actually changed). A duplicate identity in
|
|
372
|
+
the same batch is only assigned once; both look-ups return the same
|
|
373
|
+
alias."""
|
|
374
|
+
updated = {"version": registry["version"]}
|
|
375
|
+
for kind in KINDS:
|
|
376
|
+
updated[kind] = dict(registry[kind])
|
|
377
|
+
|
|
378
|
+
aliases: dict[tuple[str, str], str] = {}
|
|
379
|
+
changed = False
|
|
380
|
+
for kind, real_id in identities:
|
|
381
|
+
key = (kind, real_id)
|
|
382
|
+
if key in aliases:
|
|
383
|
+
continue
|
|
384
|
+
hashed = hmac_hex(secret, real_id)
|
|
385
|
+
table = updated[kind]
|
|
386
|
+
length = table.get(hashed)
|
|
387
|
+
if length is None:
|
|
388
|
+
length = assign_alias(table, hashed)
|
|
389
|
+
table[hashed] = length
|
|
390
|
+
changed = True
|
|
391
|
+
aliases[key] = hashed[:length]
|
|
392
|
+
return updated, aliases, changed
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# --- initialization state machine (P6) -----------------------------------------
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# N3a fix round 2 (continued), new Finding 1 (BLOCKER): how many times the
|
|
399
|
+
# state directory's own strict fsync is attempted before a registration
|
|
400
|
+
# gives up. A transient failure (fails once, succeeds on the next attempt)
|
|
401
|
+
# still succeeds; a persistent failure (fails on every attempt) does not
|
|
402
|
+
# retry forever -- it is treated exactly like any other unavailable outcome.
|
|
403
|
+
_DIRECTORY_FSYNC_ATTEMPTS = 2
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _directory_is_durable(directory: Path, *, dir_fd: int | None = None) -> bool:
|
|
407
|
+
"""N3a fix round 2 (continued), new Finding 1 (BLOCKER): a successful
|
|
408
|
+
read-back of the secret or the registry only proves the namespace
|
|
409
|
+
change is visible right now -- it says nothing about whether the
|
|
410
|
+
directory entry that makes it visible would survive a crash, because
|
|
411
|
+
the strict directory fsync (state.fsync_directory) can itself fail
|
|
412
|
+
strictly after the content it is confirming was already committed
|
|
413
|
+
(create_secret()'s own post-return fsync in _initialize_or_probe;
|
|
414
|
+
write_registry_bytes's own PostCommitVerificationError path, recovered
|
|
415
|
+
in _finalize_registration). Before accepting ANY valid secret-and-
|
|
416
|
+
registry pair as grounds to hand out stable aliases, this makes a
|
|
417
|
+
fresh attempt at that same strict fsync, retried up to
|
|
418
|
+
_DIRECTORY_FSYNC_ATTEMPTS times total. Returns True the moment one
|
|
419
|
+
attempt succeeds (a transient failure still returns True); returns
|
|
420
|
+
False only once every attempt has failed (a persistent failure), which
|
|
421
|
+
every caller here treats exactly like any other unavailable outcome:
|
|
422
|
+
positional fallback, the real secret and registry left exactly as they
|
|
423
|
+
are. dir_fd: see state.lock_state_dir/LockedStateDir (N3a fix round 3,
|
|
424
|
+
new Finding 1) -- when given, the fsync runs on that verified directory
|
|
425
|
+
descriptor directly, never a path-based reopen."""
|
|
426
|
+
for _ in range(_DIRECTORY_FSYNC_ATTEMPTS):
|
|
427
|
+
try:
|
|
428
|
+
state.fsync_directory(directory, dir_fd=dir_fd)
|
|
429
|
+
return True
|
|
430
|
+
except OSError:
|
|
431
|
+
continue
|
|
432
|
+
return False
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _probe_registry(
|
|
436
|
+
directory: Path, *, dir_fd: int | None = None
|
|
437
|
+
) -> tuple[state.Probe, dict | None]:
|
|
438
|
+
"""probe_registry_bytes's file-level outcome, upgraded to a content-level
|
|
439
|
+
one: a file that reads back as Probe.VALID bytes but fails
|
|
440
|
+
validate_registry (bad JSON, wrong schema, ...) is reported as
|
|
441
|
+
Probe.INVALID here, exactly as P4 requires -- the byte-level and
|
|
442
|
+
schema-level checks are both "the registry is unavailable" to anyone
|
|
443
|
+
outside this module.
|
|
444
|
+
|
|
445
|
+
N3a fix round 2, Finding 3: a decode failure also includes
|
|
446
|
+
RecursionError -- pathologically nested JSON (for example 10,000 nested
|
|
447
|
+
arrays) is well under MAX_BYTES but exhausts the decoder's recursion
|
|
448
|
+
budget. That must classify the registry as invalid, the same as any
|
|
449
|
+
other decode failure, not propagate out of this function.
|
|
450
|
+
|
|
451
|
+
N3a fix round 2, Finding 5: MAX_ENTRIES is also enforced here, on read,
|
|
452
|
+
not only by the write path (_write_if_within_bounds): an existing
|
|
453
|
+
registry already over the entry bound -- however small its byte size --
|
|
454
|
+
is unavailable the moment it is read.
|
|
455
|
+
|
|
456
|
+
dir_fd: see state.lock_state_dir/LockedStateDir (N3a fix round 3, new
|
|
457
|
+
Finding 1).
|
|
458
|
+
"""
|
|
459
|
+
probe, raw = state.probe_registry_bytes(
|
|
460
|
+
state.registry_path(directory), max_bytes=MAX_BYTES, dir_fd=dir_fd
|
|
461
|
+
)
|
|
462
|
+
if probe is not state.Probe.VALID:
|
|
463
|
+
return probe, None
|
|
464
|
+
try:
|
|
465
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
466
|
+
except (UnicodeDecodeError, json.JSONDecodeError, RecursionError):
|
|
467
|
+
return state.Probe.INVALID, None
|
|
468
|
+
validated = validate_registry(parsed)
|
|
469
|
+
if validated is None:
|
|
470
|
+
return state.Probe.INVALID, None
|
|
471
|
+
if count_entries(validated) > MAX_ENTRIES:
|
|
472
|
+
return state.Probe.INVALID, None
|
|
473
|
+
return state.Probe.VALID, validated
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _initialize_or_probe(
|
|
477
|
+
directory: Path, *, dir_fd: int | None = None, _retried: bool = False
|
|
478
|
+
) -> tuple[bytes | None, dict | None]:
|
|
479
|
+
"""Run P6's table once, assuming the caller already holds
|
|
480
|
+
state.lock_state_dir(directory). dir_fd: see state.lock_state_dir/
|
|
481
|
+
LockedStateDir (N3a fix round 3, new Finding 1) -- when given, every
|
|
482
|
+
secret/registry operation below runs relative to that verified
|
|
483
|
+
directory descriptor instead of by path.
|
|
484
|
+
|
|
485
|
+
Returns (secret, registry) once both
|
|
486
|
+
are available -- either because they already were ("normal") or because
|
|
487
|
+
this call just created what P6 says is safe to create -- or (None,
|
|
488
|
+
None) for every row P6 calls "unavailable". Never creates the secret
|
|
489
|
+
when the registry is valid and non-empty (that would silently re-key
|
|
490
|
+
every existing alias) and never creates the registry when the secret
|
|
491
|
+
already exists (a fresh registry next to an old secret could reassign
|
|
492
|
+
an alias that secret already vouches for). A crash between writing the
|
|
493
|
+
registry and creating the secret leaves exactly "registry valid and
|
|
494
|
+
empty, secret absent", the row this same function also handles by
|
|
495
|
+
completing the secret -- so a retried call after a mid-flight failure
|
|
496
|
+
converges correctly without any special-cased recovery path.
|
|
497
|
+
|
|
498
|
+
N3a fix round 2, Finding 1 (BLOCKER): creating the registry can fail
|
|
499
|
+
strictly after its own commit point (the registry's os.replace) with a
|
|
500
|
+
state.PostCommitVerificationError. That means the file already has its
|
|
501
|
+
new, correct content -- so instead of propagating (which would make the
|
|
502
|
+
caller report "nothing happened" while something did), this re-runs the
|
|
503
|
+
whole probe exactly once (_retried guards against looping on a
|
|
504
|
+
persistent failure): by then the just-committed file reads back through
|
|
505
|
+
the normal P6 rows above, so no separate recovery path is needed.
|
|
506
|
+
|
|
507
|
+
N3a fix round 2 (continued), new Finding 1 (BLOCKER): a read-back alone
|
|
508
|
+
is not enough, for the registry or the secret -- it only proves the
|
|
509
|
+
content is visible right now, not that the directory entry making it
|
|
510
|
+
visible would survive a crash. Every path through this function that is
|
|
511
|
+
about to return a freshly created or already-valid pair first requires
|
|
512
|
+
_directory_is_durable(directory) to succeed (its own bounded retry,
|
|
513
|
+
distinct from this function's _retried guard): a persistent fsync
|
|
514
|
+
failure there is unavailable, exactly like any other row P6 calls
|
|
515
|
+
unavailable, never a recovered result.
|
|
516
|
+
"""
|
|
517
|
+
secret_probe, secret_bytes = state.probe_secret(state.secret_path(directory), dir_fd=dir_fd)
|
|
518
|
+
registry_probe, registry = _probe_registry(directory, dir_fd=dir_fd)
|
|
519
|
+
|
|
520
|
+
if secret_probe is state.Probe.INVALID or registry_probe is state.Probe.INVALID:
|
|
521
|
+
return None, None
|
|
522
|
+
|
|
523
|
+
def _recover_once() -> tuple[bytes | None, dict | None]:
|
|
524
|
+
if _retried:
|
|
525
|
+
return None, None
|
|
526
|
+
return _initialize_or_probe(directory, dir_fd=dir_fd, _retried=True)
|
|
527
|
+
|
|
528
|
+
if registry_probe is state.Probe.ABSENT and secret_probe is state.Probe.ABSENT:
|
|
529
|
+
fresh = empty_registry()
|
|
530
|
+
try:
|
|
531
|
+
state.write_registry_bytes(
|
|
532
|
+
state.registry_path(directory), _dumps(fresh), dir_fd=dir_fd
|
|
533
|
+
)
|
|
534
|
+
except state.PostCommitVerificationError:
|
|
535
|
+
return _recover_once()
|
|
536
|
+
# create_secret() itself is NOT wrapped here: a failure inside it is
|
|
537
|
+
# always pre-commit (it cleans up the file it just created and
|
|
538
|
+
# raises), so it must propagate normally, exactly as before this
|
|
539
|
+
# fix -- only the separate, strictly post-commit directory
|
|
540
|
+
# durability confirmation below can fail without undoing anything.
|
|
541
|
+
new_secret = state.create_secret(state.secret_path(directory), dir_fd=dir_fd)
|
|
542
|
+
if not _directory_is_durable(directory, dir_fd=dir_fd):
|
|
543
|
+
return None, None
|
|
544
|
+
return new_secret, fresh
|
|
545
|
+
|
|
546
|
+
if registry_probe is state.Probe.VALID and is_empty_registry(registry) and (
|
|
547
|
+
secret_probe is state.Probe.ABSENT
|
|
548
|
+
):
|
|
549
|
+
new_secret = state.create_secret(state.secret_path(directory), dir_fd=dir_fd)
|
|
550
|
+
if not _directory_is_durable(directory, dir_fd=dir_fd):
|
|
551
|
+
return None, None
|
|
552
|
+
return new_secret, registry
|
|
553
|
+
|
|
554
|
+
if registry_probe is state.Probe.VALID and not is_empty_registry(registry) and (
|
|
555
|
+
secret_probe is state.Probe.ABSENT
|
|
556
|
+
):
|
|
557
|
+
return None, None # a new secret would re-key every existing alias
|
|
558
|
+
|
|
559
|
+
if registry_probe is state.Probe.ABSENT and secret_probe is state.Probe.VALID:
|
|
560
|
+
return None, None # a new registry could reassign an alias the secret vouches for
|
|
561
|
+
|
|
562
|
+
# registry_probe is VALID and secret_probe is VALID: normal, but a prior
|
|
563
|
+
# process's successful read-back here cannot distinguish a genuinely
|
|
564
|
+
# durable commit from one whose own directory fsync failed right after
|
|
565
|
+
# the file became visible (N3a fix round 2 continued, new Finding 1).
|
|
566
|
+
# Require a fresh, successful directory fsync before trusting this pair
|
|
567
|
+
# enough to hand out stable aliases; a persistent failure here is
|
|
568
|
+
# unavailable, exactly like any invalid file.
|
|
569
|
+
if not _directory_is_durable(directory, dir_fd=dir_fd):
|
|
570
|
+
return None, None
|
|
571
|
+
return secret_bytes, registry
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _write_if_within_bounds(directory: Path, registry: dict, *, dir_fd: int | None = None) -> bool:
|
|
575
|
+
"""Encode registry and write it, unless doing so would take the file
|
|
576
|
+
over MAX_BYTES or MAX_ENTRIES, in which case nothing is written at all
|
|
577
|
+
and this returns False (the caller treats that exactly like any other
|
|
578
|
+
registration failure: fall back, leave the file untouched). May raise
|
|
579
|
+
state.PostCommitVerificationError (N3a fix round 2, Finding 1): the
|
|
580
|
+
write's own commit point already passed; the caller decides whether to
|
|
581
|
+
recover rather than assume nothing changed. dir_fd: see state.
|
|
582
|
+
lock_state_dir/LockedStateDir (N3a fix round 3, new Finding 1)."""
|
|
583
|
+
if count_entries(registry) > MAX_ENTRIES:
|
|
584
|
+
return False
|
|
585
|
+
encoded = _dumps(registry)
|
|
586
|
+
if len(encoded) > MAX_BYTES:
|
|
587
|
+
return False
|
|
588
|
+
state.write_registry_bytes(state.registry_path(directory), encoded, dir_fd=dir_fd)
|
|
589
|
+
return True
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _finalize_registration(
|
|
593
|
+
directory: Path,
|
|
594
|
+
registry: dict,
|
|
595
|
+
secret: bytes,
|
|
596
|
+
canonical: list[tuple[str, str]],
|
|
597
|
+
*,
|
|
598
|
+
dir_fd: int | None = None,
|
|
599
|
+
) -> dict[tuple[str, str], str] | None:
|
|
600
|
+
"""Compute aliases for every (kind, real_id) in canonical against
|
|
601
|
+
registry/secret (P3), persist the result if anything changed, and
|
|
602
|
+
return the {(kind, real_id): alias} mapping -- or None if this
|
|
603
|
+
registration cannot complete (a bound would be exceeded, or a
|
|
604
|
+
post-commit recovery attempt itself does not confirm the write).
|
|
605
|
+
dir_fd: see state.lock_state_dir/LockedStateDir (N3a fix round 3, new
|
|
606
|
+
Finding 1) -- threaded into every call below.
|
|
607
|
+
|
|
608
|
+
N3a fix round 2, Finding 1 (BLOCKER): a state.PostCommitVerificationError
|
|
609
|
+
from _write_if_within_bounds means the write's own commit point
|
|
610
|
+
(os.replace) already passed -- aliases.json already holds `updated`.
|
|
611
|
+
Re-probe the registry fresh, under the same lock: if it reads back
|
|
612
|
+
valid and byte-for-byte equal (as parsed dicts) to what was just
|
|
613
|
+
written, aliases is still correct and this call succeeded; otherwise
|
|
614
|
+
report failure rather than silently claim nothing changed.
|
|
615
|
+
|
|
616
|
+
N3a fix round 2 (continued), new Finding 1 (BLOCKER): content equality
|
|
617
|
+
alone is not enough either -- it only proves the registry is visible
|
|
618
|
+
with the right bytes right now, not that its directory entry would
|
|
619
|
+
survive a crash. Require a fresh, successful directory fsync too before
|
|
620
|
+
accepting this recovery; a persistent failure there reports failure the
|
|
621
|
+
same as a content mismatch.
|
|
622
|
+
"""
|
|
623
|
+
updated, aliases, changed = _assign_all(registry, secret, canonical)
|
|
624
|
+
if not changed:
|
|
625
|
+
return aliases
|
|
626
|
+
try:
|
|
627
|
+
if not _write_if_within_bounds(directory, updated, dir_fd=dir_fd):
|
|
628
|
+
return None
|
|
629
|
+
except state.PostCommitVerificationError:
|
|
630
|
+
reprobed_probe, reprobed = _probe_registry(directory, dir_fd=dir_fd)
|
|
631
|
+
if reprobed_probe is not state.Probe.VALID or reprobed != updated:
|
|
632
|
+
return None
|
|
633
|
+
if not _directory_is_durable(directory, dir_fd=dir_fd):
|
|
634
|
+
return None
|
|
635
|
+
return aliases
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# --- AliasBook and registration (P7, P8, P9) -----------------------------------
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@dataclass
|
|
642
|
+
class AliasBook:
|
|
643
|
+
"""One batch's worth of stable aliases, answered from memory after one
|
|
644
|
+
locked registration (P9). available is False for a fallback book:
|
|
645
|
+
every alias it hands out is positional, and it never holds a real id
|
|
646
|
+
anywhere in its own state (it is simply never given one -- a fallback
|
|
647
|
+
book is built from the same canonical (kind, real_id) pairs as a real
|
|
648
|
+
one, but only ever stores the positional name assigned to each).
|
|
649
|
+
"""
|
|
650
|
+
|
|
651
|
+
available: bool
|
|
652
|
+
slow_path_count: int = 0
|
|
653
|
+
_directory: Path | None = field(default=None, repr=False)
|
|
654
|
+
_aliases: dict[tuple[str, str], str] = field(default_factory=dict, repr=False)
|
|
655
|
+
_positional: dict[tuple[str, str], str] = field(default_factory=dict, repr=False)
|
|
656
|
+
_positional_counts: dict[str, int] = field(default_factory=dict, repr=False)
|
|
657
|
+
# N3b D-i: an in-memory book (the demo) keeps its registry and secret
|
|
658
|
+
# here and never touches the state directory, not even on the slow path.
|
|
659
|
+
_memory: dict | None = field(default=None, repr=False)
|
|
660
|
+
_secret: bytes | None = field(default=None, repr=False)
|
|
661
|
+
|
|
662
|
+
@property
|
|
663
|
+
def in_memory(self) -> bool:
|
|
664
|
+
return self._memory is not None
|
|
665
|
+
|
|
666
|
+
def alias(self, kind: str, real_id: str) -> str:
|
|
667
|
+
"""The alias for (kind, real_id). An identity this book was already
|
|
668
|
+
registered with answers from memory, instantly. An identity it was
|
|
669
|
+
not given at construction takes the slow path (docs/DESIGN.md 7.5/
|
|
670
|
+
P9): one more locked registration attempt for just this id,
|
|
671
|
+
counted in slow_path_count regardless of whether that attempt
|
|
672
|
+
itself succeeds or falls back; a fallback book has no lock to take
|
|
673
|
+
and simply extends its own positional numbering instead, without
|
|
674
|
+
touching slow_path_count at all.
|
|
675
|
+
"""
|
|
676
|
+
key = (kind, real_id)
|
|
677
|
+
if key in self._aliases:
|
|
678
|
+
return self._aliases[key]
|
|
679
|
+
if key in self._positional:
|
|
680
|
+
return self._positional[key]
|
|
681
|
+
|
|
682
|
+
if not self.available:
|
|
683
|
+
return self._assign_positional(kind, real_id)
|
|
684
|
+
|
|
685
|
+
# N3a fix round 2, Finding 7: the slow path must apply register()'s
|
|
686
|
+
# own P2 validation before doing anything else. A kind alias() does
|
|
687
|
+
# not recognize, or an id that is not canonical for its kind, is
|
|
688
|
+
# never eligible for a stable alias at all -- sending it on to
|
|
689
|
+
# _finalize_registration would HMAC and persist a non-canonical id,
|
|
690
|
+
# or index a per-kind table with a kind that was never one of them
|
|
691
|
+
# (a raw KeyError). This is not a registration failure, just an
|
|
692
|
+
# identity that was never going to get a stable alias, so it does
|
|
693
|
+
# not take the lock and does not count against slow_path_count or
|
|
694
|
+
# degrade the rest of the book (contrast Finding 8 below).
|
|
695
|
+
if kind not in KINDS or not is_canonical_id(kind, real_id):
|
|
696
|
+
return self._assign_positional(kind, real_id)
|
|
697
|
+
|
|
698
|
+
if self._memory is not None:
|
|
699
|
+
return self._alias_in_memory(kind, real_id)
|
|
700
|
+
|
|
701
|
+
self.slow_path_count += 1
|
|
702
|
+
try:
|
|
703
|
+
with state.lock_state_dir(self._directory) as locked:
|
|
704
|
+
secret, registry = _initialize_or_probe(locked.path, dir_fd=locked.dir_fd)
|
|
705
|
+
if secret is None or registry is None:
|
|
706
|
+
raise DisclosureError("disclosure state became unavailable")
|
|
707
|
+
aliases = _finalize_registration(
|
|
708
|
+
locked.path, registry, secret, [(kind, real_id)], dir_fd=locked.dir_fd
|
|
709
|
+
)
|
|
710
|
+
if aliases is None:
|
|
711
|
+
raise DisclosureError("registry bound exceeded on the slow path")
|
|
712
|
+
alias = aliases[key]
|
|
713
|
+
self._aliases[key] = alias
|
|
714
|
+
return alias
|
|
715
|
+
except Exception:
|
|
716
|
+
# N3a fix round 2, Finding 3: an ordinary bug anywhere in
|
|
717
|
+
# probing, assignment, or writing (not just the state/disclosure
|
|
718
|
+
# errors this module itself defines) must still fall back,
|
|
719
|
+
# never raise out of alias(). KeyboardInterrupt/SystemExit are
|
|
720
|
+
# not ordinary Exceptions and still propagate.
|
|
721
|
+
#
|
|
722
|
+
# N3a fix round 2, Finding 8: a genuine slow-path failure means
|
|
723
|
+
# this book can no longer vouch for any alias it already handed
|
|
724
|
+
# out from memory either -- a mix of stable and positional
|
|
725
|
+
# aliases within one render is exactly what P8 forbids. Degrade
|
|
726
|
+
# the whole book atomically rather than falling back for only
|
|
727
|
+
# the one identity that triggered the failure.
|
|
728
|
+
return self._degrade_and_assign_positional(kind, real_id)
|
|
729
|
+
|
|
730
|
+
def _alias_in_memory(self, kind: str, real_id: str) -> str:
|
|
731
|
+
"""The slow path of an in-memory book: one more assignment against
|
|
732
|
+
the in-memory registry, counted like the locked one, never a file."""
|
|
733
|
+
key = (kind, real_id)
|
|
734
|
+
self.slow_path_count += 1
|
|
735
|
+
try:
|
|
736
|
+
assert self._memory is not None and self._secret is not None
|
|
737
|
+
updated, aliases, _changed = _assign_all(self._memory, self._secret, [key])
|
|
738
|
+
self._memory = updated
|
|
739
|
+
alias = aliases[key]
|
|
740
|
+
self._aliases[key] = alias
|
|
741
|
+
return alias
|
|
742
|
+
except Exception:
|
|
743
|
+
return self._degrade_and_assign_positional(kind, real_id)
|
|
744
|
+
|
|
745
|
+
def _assign_positional(self, kind: str, real_id: str) -> str:
|
|
746
|
+
key = (kind, real_id)
|
|
747
|
+
if key in self._positional:
|
|
748
|
+
return self._positional[key]
|
|
749
|
+
n = self._positional_counts.get(kind, 0) + 1
|
|
750
|
+
self._positional_counts[kind] = n
|
|
751
|
+
name = f"{kind}-{n}"
|
|
752
|
+
self._positional[key] = name
|
|
753
|
+
return name
|
|
754
|
+
|
|
755
|
+
def _degrade_and_assign_positional(self, kind: str, real_id: str) -> str:
|
|
756
|
+
"""N3a fix round 2, Finding 8: flip available to False and replace
|
|
757
|
+
every alias this book had already handed out from memory with a
|
|
758
|
+
positional one, in the order those identities were first
|
|
759
|
+
registered (self._aliases already holds them in that order, since
|
|
760
|
+
register() builds it from one ordered pass over its identities
|
|
761
|
+
list), before assigning the new, also positional, name. A caller
|
|
762
|
+
that already printed an earlier stable alias cannot have that
|
|
763
|
+
un-said -- P8's one-shot re-render rule (N3b) is what makes a
|
|
764
|
+
one-shot command notice this mid-render and start over before
|
|
765
|
+
anything reaches real output; `watch`'s own rule (C-1), that
|
|
766
|
+
already-emitted lines stand and only the current and later events
|
|
767
|
+
go positional, falls out of the same "available flips to False from
|
|
768
|
+
here on" behavior for free.
|
|
769
|
+
"""
|
|
770
|
+
self.available = False
|
|
771
|
+
previously_stable = list(self._aliases.keys())
|
|
772
|
+
self._aliases.clear()
|
|
773
|
+
for prior_kind, prior_id in previously_stable:
|
|
774
|
+
self._assign_positional(prior_kind, prior_id)
|
|
775
|
+
return self._assign_positional(kind, real_id)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _fallback_book(identities: list[tuple[str, str]]) -> AliasBook:
|
|
779
|
+
book = AliasBook(available=False)
|
|
780
|
+
for kind, real_id in identities:
|
|
781
|
+
book._assign_positional(kind, real_id)
|
|
782
|
+
return book
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def fallback_book(identities: list[tuple[str, str]] | None = None) -> AliasBook:
|
|
786
|
+
"""A positional-only book (available=False) numbered in the order of
|
|
787
|
+
`identities`, then in first-use order. N3b D-b uses it to re-render one
|
|
788
|
+
output once, from the same collected identities, when a stable book
|
|
789
|
+
degraded mid-render. Touches no file."""
|
|
790
|
+
return _fallback_book(list(identities or []))
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def lookup_agent_alias(
|
|
794
|
+
alias_hex: str, ids_by_ref, *, state_dir: Path | None = None
|
|
795
|
+
) -> dict | None:
|
|
796
|
+
"""docs/DESIGN.md 4.13.6 (N6): which of the agent ids in `ids_by_ref` (a mapping from
|
|
797
|
+
an opaque ref to the agent ids to test) hold exactly the registered alias
|
|
798
|
+
`alias_hex`. Returns {ref: frozenset of matching ids}, only refs with a match, or
|
|
799
|
+
None when stable aliases are unavailable on this machine.
|
|
800
|
+
|
|
801
|
+
Read-only: it opens the state directory with state._open_verified_state_dir (a
|
|
802
|
+
directory, mode 0700, owned by the current user, no symlink) and reads the secret
|
|
803
|
+
and the registry relative to that one descriptor, closing it once before returning
|
|
804
|
+
(a close OSError is unavailable too). It takes no lock, creates nothing, repairs
|
|
805
|
+
nothing, writes nothing. An absent directory, a StateError, an OSError, or a probe
|
|
806
|
+
result other than VALID all mean unavailable; no
|
|
807
|
+
exception escapes. An id never registered has no alias and never matches; an alias
|
|
808
|
+
matches only the identity whose registered alias is exactly `alias_hex` (a longer
|
|
809
|
+
registered alias that merely starts with it belongs to another identity)."""
|
|
810
|
+
directory = state_dir if state_dir is not None else state.state_dir()
|
|
811
|
+
try:
|
|
812
|
+
dir_fd = state._open_verified_state_dir(directory)
|
|
813
|
+
except Exception:
|
|
814
|
+
return None
|
|
815
|
+
try:
|
|
816
|
+
result = _lookup_with(directory, dir_fd, alias_hex, ids_by_ref)
|
|
817
|
+
except Exception:
|
|
818
|
+
result = None
|
|
819
|
+
try:
|
|
820
|
+
os.close(dir_fd)
|
|
821
|
+
except OSError:
|
|
822
|
+
return None
|
|
823
|
+
return result
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _lookup_with(directory: Path, dir_fd: int, alias_hex: str, ids_by_ref) -> dict | None:
|
|
827
|
+
"""The probes and the match of lookup_agent_alias, over the one verified descriptor."""
|
|
828
|
+
secret_probe, secret = state.probe_secret(state.secret_path(directory), dir_fd=dir_fd)
|
|
829
|
+
if secret_probe is not state.Probe.VALID or secret is None:
|
|
830
|
+
return None
|
|
831
|
+
registry_probe, registry = _probe_registry(directory, dir_fd=dir_fd)
|
|
832
|
+
if registry_probe is not state.Probe.VALID or registry is None:
|
|
833
|
+
return None
|
|
834
|
+
table = registry["agent"]
|
|
835
|
+
matched: dict = {}
|
|
836
|
+
for ref, agent_ids in ids_by_ref.items():
|
|
837
|
+
hits = set()
|
|
838
|
+
for agent_id in agent_ids:
|
|
839
|
+
if not is_canonical_id("agent", agent_id):
|
|
840
|
+
continue
|
|
841
|
+
hashed = hmac_hex(secret, agent_id)
|
|
842
|
+
length = table.get(hashed)
|
|
843
|
+
if length is not None and hashed[:length] == alias_hex:
|
|
844
|
+
hits.add(agent_id)
|
|
845
|
+
if hits:
|
|
846
|
+
matched[ref] = frozenset(hits)
|
|
847
|
+
return matched
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def lazy_book(*, state_dir: Path | None = None) -> AliasBook:
|
|
851
|
+
"""N3b C-1: an available book that has registered nothing yet. `watch`
|
|
852
|
+
uses it when no agent is known at start: the first canonical identity it
|
|
853
|
+
is asked for takes the slow path (one locked registration), and a
|
|
854
|
+
non-canonical one is named positionally without touching any file."""
|
|
855
|
+
directory = state_dir if state_dir is not None else state.state_dir()
|
|
856
|
+
return AliasBook(available=True, _directory=directory)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
# N3b D-i: the secret `demo` renders with. It is PUBLIC on purpose (it is in
|
|
860
|
+
# the source of every copy of the tool): the demo shows one synthetic run, so
|
|
861
|
+
# its aliases must be the same on every machine, and it must never read or
|
|
862
|
+
# write a real installation secret. 32 bytes, derived from a fixed phrase.
|
|
863
|
+
DEMO_SECRET: bytes = hashlib.sha256(b"agent-postmortem public demo secret").digest()
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def register_in_memory(
|
|
867
|
+
identities: list[tuple[str, str]], secret: bytes
|
|
868
|
+
) -> AliasBook:
|
|
869
|
+
"""N3b D-i: the demo's registration. P3 assignment against an empty,
|
|
870
|
+
in-memory registry, the identities in the order given (one fixed order,
|
|
871
|
+
which is the collision tiebreak), under `secret`. Touches no file: no
|
|
872
|
+
state directory, no lock, no secret, no registry. The book's own slow
|
|
873
|
+
path also stays in memory. Non-canonical ids raise ValueError, as in
|
|
874
|
+
register()."""
|
|
875
|
+
canonical = [(kind, require_canonical_id(kind, real_id)) for kind, real_id in identities]
|
|
876
|
+
registry, aliases, _changed = _assign_all(empty_registry(), secret, canonical)
|
|
877
|
+
book = AliasBook(available=True)
|
|
878
|
+
book._memory = registry
|
|
879
|
+
book._secret = secret
|
|
880
|
+
book._aliases.update(aliases)
|
|
881
|
+
return book
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def register(
|
|
885
|
+
identities: list[tuple[str, str]], *, state_dir: Path | None = None
|
|
886
|
+
) -> AliasBook:
|
|
887
|
+
"""One locked registration for every identity a command already knows
|
|
888
|
+
it may render (docs/DESIGN.md 7.5/P9). identities is a list of
|
|
889
|
+
(kind, real_id) pairs, kind one of KINDS; every one is validated as
|
|
890
|
+
canonical (require_canonical_id) before the lock is even taken, so a
|
|
891
|
+
caller's bug (a non-canonical id slipping through) raises ValueError
|
|
892
|
+
immediately rather than silently becoming a positional name here (N3b's
|
|
893
|
+
renderer is what chooses a positional name for a non-canonical id).
|
|
894
|
+
|
|
895
|
+
Returns an AliasBook. On any failure in P4/P6/P7/P8 terms --
|
|
896
|
+
unavailable secret or registry, lock contention, a failed write, a
|
|
897
|
+
bound that would be exceeded -- returns a fallback book instead
|
|
898
|
+
(available=False, positional names only). A pre-commit failure leaves
|
|
899
|
+
the secret and the registry byte-for-byte and mtime untouched: nothing
|
|
900
|
+
between taking the lock and returning ever partially writes one of the
|
|
901
|
+
two files and then gives up. A post-commit failure (docs/DESIGN.md
|
|
902
|
+
7.5a) is the one exception: the new file may already be installed, and
|
|
903
|
+
this fallback is what register() returns only once re-probing it and
|
|
904
|
+
requiring a fresh directory-durability confirmation
|
|
905
|
+
have both still failed to confirm it, never by silently pretending
|
|
906
|
+
nothing changed.
|
|
907
|
+
"""
|
|
908
|
+
directory = state_dir if state_dir is not None else state.state_dir()
|
|
909
|
+
canonical = [(kind, require_canonical_id(kind, real_id)) for kind, real_id in identities]
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
with state.lock_state_dir(directory) as locked:
|
|
913
|
+
secret, registry = _initialize_or_probe(locked.path, dir_fd=locked.dir_fd)
|
|
914
|
+
if secret is None or registry is None:
|
|
915
|
+
return _fallback_book(canonical)
|
|
916
|
+
aliases = _finalize_registration(
|
|
917
|
+
locked.path, registry, secret, canonical, dir_fd=locked.dir_fd
|
|
918
|
+
)
|
|
919
|
+
if aliases is None:
|
|
920
|
+
return _fallback_book(canonical)
|
|
921
|
+
except Exception:
|
|
922
|
+
# N3a fix round 2, Finding 3: fall back on any ordinary exception
|
|
923
|
+
# from probing, assignment, or writing -- not just the specific
|
|
924
|
+
# state/disclosure error types this module already knew about.
|
|
925
|
+
# require_canonical_id above still raises ValueError before the
|
|
926
|
+
# lock is ever taken, for a caller's own bug.
|
|
927
|
+
return _fallback_book(canonical)
|
|
928
|
+
|
|
929
|
+
book = AliasBook(available=True, _directory=directory)
|
|
930
|
+
book._aliases.update(aliases)
|
|
931
|
+
return book
|