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.
Files changed (56) hide show
  1. agent_postmortem/__init__.py +9 -0
  2. agent_postmortem/argerrors.py +160 -0
  3. agent_postmortem/claims.py +43 -0
  4. agent_postmortem/cli.py +2278 -0
  5. agent_postmortem/commands.py +1543 -0
  6. agent_postmortem/config.py +435 -0
  7. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
  8. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
  9. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
  10. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
  11. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
  12. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
  13. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
  14. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
  15. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
  16. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
  17. agent_postmortem/detectors/__init__.py +98 -0
  18. agent_postmortem/detectors/attempt_failed.py +261 -0
  19. agent_postmortem/detectors/base.py +319 -0
  20. agent_postmortem/detectors/bash_backgrounded.py +72 -0
  21. agent_postmortem/detectors/bash_timeout.py +94 -0
  22. agent_postmortem/detectors/claim_contradiction.py +251 -0
  23. agent_postmortem/detectors/classifier_unavailable.py +73 -0
  24. agent_postmortem/detectors/codex_unavailable.py +102 -0
  25. agent_postmortem/detectors/codex_write_denied.py +100 -0
  26. agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
  27. agent_postmortem/detectors/mislabeled_kill.py +164 -0
  28. agent_postmortem/detectors/missing_artifact.py +159 -0
  29. agent_postmortem/detectors/permission_denied.py +99 -0
  30. agent_postmortem/detectors/silent_retry.py +163 -0
  31. agent_postmortem/detectors/structured_output_failure.py +91 -0
  32. agent_postmortem/detectors/tool_json_error.py +77 -0
  33. agent_postmortem/detectors/wasted_time.py +125 -0
  34. agent_postmortem/detectors/workflow_failed.py +62 -0
  35. agent_postmortem/disclosure.py +931 -0
  36. agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
  37. agent_postmortem/hooks_cli.py +1269 -0
  38. agent_postmortem/ledger.py +624 -0
  39. agent_postmortem/markers.py +505 -0
  40. agent_postmortem/models.py +815 -0
  41. agent_postmortem/observe.py +792 -0
  42. agent_postmortem/reader.py +4611 -0
  43. agent_postmortem/redact.py +1981 -0
  44. agent_postmortem/report.py +3010 -0
  45. agent_postmortem/rescue.py +2096 -0
  46. agent_postmortem/returns.py +499 -0
  47. agent_postmortem/rules.py +627 -0
  48. agent_postmortem/severity.py +275 -0
  49. agent_postmortem/state.py +1261 -0
  50. agent_postmortem/trend.py +391 -0
  51. agent_postmortem/watch.py +973 -0
  52. agent_postmortem-0.3.4.dist-info/METADATA +264 -0
  53. agent_postmortem-0.3.4.dist-info/RECORD +56 -0
  54. agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
  55. agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
  56. agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1261 @@
1
+ """Where postmortem remembers which rules it has applied, and what it has
2
+ already reported to a session's transcript.
3
+
4
+ State path: $XDG_STATE_HOME/agent-postmortem/state.json, or
5
+ ~/.local/state/agent-postmortem/state.json if XDG_STATE_HOME is unset or
6
+ empty. The containing directory is created mode 0700; state.json and
7
+ state.lock are written mode 0600. This is the one place raw session ids and
8
+ locators may be stored; nothing in it is ever printed by any renderer
9
+ (docs/DESIGN.md section 2).
10
+
11
+ Schema version 2:
12
+ {"version": 2, "min_reader": 2,
13
+ "applied": [{"rule_id", "target", "applied_at"}],
14
+ "sessions": {"<session-id>": {"units": {...}, "last_check": iso}},
15
+ "digest": {"pending": ["<unit-id>", ...]}}
16
+
17
+ transaction() is the only write path; update(fn) is a thin wrapper around it
18
+ for the common "read, mutate, write" case. transaction() acquires an
19
+ exclusive, non-blocking flock on state.lock, retrying in short
20
+ (LOCK_POLL_SECONDS) steps for up to LOCK_TIMEOUT_SECONDS before giving up and
21
+ raising StateLocked -- never a single blocking flock call, never a busy loop
22
+ longer than the timeout, and never a retry for an OSError from flock that
23
+ is not actual contention (BlockingIOError, EAGAIN, EACCES); any other OSError
24
+ propagates immediately as StateError. Once the lock is held it loads and
25
+ migrates the current file and yields it; on normal exit it writes the
26
+ (possibly mutated) result back atomically -- a temp file in the same
27
+ directory, fsynced, renamed over state.json, then the containing directory
28
+ itself fsynced -- and verifies the final permissions by stat before
29
+ releasing the lock. If the caller's code raises instead, nothing is written.
30
+ `applied` (docs/DESIGN.md 9) is legacy: nothing in this build ever appends a
31
+ new entry to it (rules.apply_rule() only ever reads a target file, never
32
+ writes one), but existing entries are still read here, migrated, and kept,
33
+ so a v0.1 state file's history still feeds `rules review`.
34
+
35
+ The state directory and state.lock are lstat-checked before every use and
36
+ rejected as StateError if either is a symlink or the wrong kind of thing
37
+ (directory expected for one, a regular file for the other); state.lock is
38
+ then opened with O_NOFOLLOW where the platform supports it. mkdir(exist_ok=
39
+ True) and a plain O_CREAT open both silently accept an existing symlink,
40
+ which is exactly the gap this closes.
41
+
42
+ A file whose "version", or whose "min_reader", is newer than this reader
43
+ understands raises StateTooNew before anything else is touched: never
44
+ written, never backed up, never migrated. A file that is not valid UTF-8,
45
+ is not valid JSON once decoded, or whose JSON is not the right shape at
46
+ all, is copied aside to "state.json.corrupt-<UTC timestamp with
47
+ microseconds>" (O_CREAT | O_EXCL, mode 0600, a numeric-suffix retry on a
48
+ same-microsecond collision -- never check-then-create) and a fresh v2 state
49
+ is used in its place. A file that parses and carries a version this reader
50
+ understands, but has a malformed namespace or a malformed entry inside an
51
+ otherwise well-formed one (for example one bad dict inside "applied", or
52
+ "sessions" being a string instead of a dict), is backed up the same way,
53
+ but only what is actually malformed is dropped -- every well-formed entry,
54
+ in every namespace, survives untouched. If the backup itself cannot be
55
+ created or written, that raises StateError and nothing is reset: docs/
56
+ DESIGN.md 7.5's "never reset without a completed backup".
57
+
58
+ A filesystem failure reading a file that does exist (permission denied, an
59
+ I/O error) is not corruption and is never treated as one: it propagates as
60
+ an OSError instead of silently resetting state that is actually fine but
61
+ unreadable right now.
62
+
63
+ load_state() is a read-only convenience for callers that only want the
64
+ current state (the CLI's `trend` command, for instance): it runs the same
65
+ load-and-migrate path as transaction(), including the StateTooNew guard,
66
+ but never writes anything -- not a migrated v1 file, not a repaired
67
+ namespace, and not a corrupt-file backup. Only transaction()/update(),
68
+ under the lock, ever persist a migration, a repair, or a backup.
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ import errno
74
+ import fcntl
75
+ import json
76
+ import os
77
+ import secrets
78
+ import stat
79
+ import tempfile
80
+ import time
81
+ from collections.abc import Callable, Iterator
82
+ from contextlib import contextmanager
83
+ from dataclasses import dataclass
84
+ from datetime import datetime, timezone
85
+ from enum import Enum
86
+ from pathlib import Path
87
+
88
+ # open()/os.open() with O_NOFOLLOW refuses to follow a symlink in the final
89
+ # path component; not every platform has the flag (Windows does not), so
90
+ # this degrades to 0 (no extra protection) there rather than failing import.
91
+ _NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
92
+
93
+ # O_DIRECTORY makes an open() fail outright if the final path component is
94
+ # not a directory, instead of silently succeeding (N3a fix round 2,
95
+ # Finding 4). Same degrade-to-0 reasoning as _NOFOLLOW above.
96
+ _O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
97
+
98
+ # N3a fix round 3, new Finding 1: the real os.open/os.unlink/os.rename
99
+ # function objects, captured once at import time, before any test (or
100
+ # anything else) can have monkeypatched os.open etc. for an unrelated
101
+ # reason. _dir_fd_capable() below checks membership of THESE captured
102
+ # objects in the live os.supports_dir_fd set, not of whatever os.open
103
+ # currently resolves to: many existing tests wrap os.open in a tracking
104
+ # shim for reasons that have nothing to do with dir_fd support (fstat
105
+ # tampering, call counting, ...), and a tracking shim is never itself a
106
+ # member of os.supports_dir_fd -- checking the live os.open would make the
107
+ # capability check spuriously fail (and silently mask the real check this
108
+ # module actually needs to prove) the moment any such test wraps it.
109
+ # os.supports_dir_fd itself is still read live (os.supports_dir_fd, not a
110
+ # captured copy), so a test can monkeypatch that specific attribute to
111
+ # exercise the fail-closed path on a platform that is actually capable.
112
+ _DIR_FD_OPEN = os.open
113
+ _DIR_FD_UNLINK = os.unlink
114
+ _DIR_FD_RENAME = os.rename
115
+
116
+ _VERSION = 2
117
+ _MIN_READER = 2
118
+
119
+ LOCK_TIMEOUT_SECONDS = 2.0
120
+ LOCK_POLL_SECONDS = 0.05
121
+
122
+ # How many times _open_lock_file_at tries the descriptor-relative create of
123
+ # state.lock when it fails with ENOENT (see that function).
124
+ _LOCK_CREATE_ATTEMPTS = 5
125
+
126
+ # Only these mean "someone else holds the lock right now": retry until the
127
+ # timeout. Any other OSError from flock (EBADF, EINVAL, ...) is a real
128
+ # error, not contention, and must propagate right away.
129
+ _CONTENTION_ERRNOS = {errno.EAGAIN, errno.EACCES}
130
+ if hasattr(errno, "EWOULDBLOCK"):
131
+ _CONTENTION_ERRNOS.add(errno.EWOULDBLOCK)
132
+
133
+
134
+ class StateError(Exception):
135
+ """Base class for state store errors that a caller should report,
136
+ never one it should silently swallow into "no state"."""
137
+
138
+
139
+ class StateLocked(StateError):
140
+ """state.lock could not be acquired within LOCK_TIMEOUT_SECONDS. The
141
+ CLI reports this to the user; a hook (docs/DESIGN.md 7.2) catches it
142
+ and stays silent rather than failing a turn."""
143
+
144
+
145
+ class StateTooNew(StateError):
146
+ """state.json's version is newer than this reader supports. The file
147
+ is left exactly as it was: not written, not migrated, not backed up."""
148
+
149
+
150
+ class PostCommitVerificationError(StateError):
151
+ """N3a fix round 2, Finding 1 (BLOCKER): raised only after a disclosure
152
+ write's actual commit point has already passed -- os.replace() for the
153
+ alias registry, or (for the installation secret, whose own commit
154
+ point is simply "create_secret() returned without raising") the
155
+ disclosure-specific strict directory fsync that runs right after it --
156
+ by a step that is itself strictly post-commit (the strict directory
157
+ fsync, or the final post-commit reverification: a reopen of the final
158
+ path with O_NOFOLLOW, verified from that descriptor, never a later,
159
+ separate, symlink-following stat() of the path). The file on disk
160
+ already reflects the new content; a caller must never treat this the
161
+ same way as a failure before the commit point. agent_postmortem.
162
+ disclosure re-probes the exact file under the same lock and returns
163
+ the committed result if it still reads back valid and equal to what
164
+ was written, else falls back (docs/DESIGN.md 7.5a)."""
165
+
166
+
167
+ def now_iso() -> str:
168
+ """The current time as an ISO 8601 UTC timestamp, second precision."""
169
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
170
+
171
+
172
+ def _now_iso_micro() -> str:
173
+ """UTC timestamp with microseconds, safe to use as (most of) a
174
+ filename: no colons, no plus sign."""
175
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
176
+
177
+
178
+ def default_state() -> dict:
179
+ return {
180
+ "version": _VERSION,
181
+ "min_reader": _MIN_READER,
182
+ "applied": [],
183
+ "sessions": {},
184
+ "digest": {"pending": []},
185
+ }
186
+
187
+
188
+ def state_dir() -> Path:
189
+ """$XDG_STATE_HOME/agent-postmortem, else ~/.local/state/agent-postmortem.
190
+
191
+ Reads the environment at call time, not import time, so a test can
192
+ monkeypatch XDG_STATE_HOME per test without reloading this module.
193
+ """
194
+ xdg = os.environ.get("XDG_STATE_HOME", "").strip()
195
+ base = Path(xdg) if xdg else Path.home() / ".local" / "state"
196
+ return base / "agent-postmortem"
197
+
198
+
199
+ def state_path() -> Path:
200
+ return state_dir() / "state.json"
201
+
202
+
203
+ def _lock_path(state_file: Path) -> Path:
204
+ return state_file.parent / "state.lock"
205
+
206
+
207
+ def _backup_writer(state_file: Path) -> Callable[[bytes], None]:
208
+ """A backup() callback for _load_and_migrate, bound to state_file.
209
+
210
+ Must only be invoked while state.lock is held (update()/transaction()):
211
+ the state lock is what makes the "no two backups collide, no backup
212
+ check-then-create race" guarantee true. Creates
213
+ 'state.json.corrupt-<timestamp>[-N]' with O_CREAT | O_EXCL, mode 0600,
214
+ retrying with a numeric suffix on FileExistsError rather than checking
215
+ existence first and creating second. Raises StateError, never silently
216
+ swallowed, if the backup cannot be created or written -- docs/
217
+ DESIGN.md 7.5: never reset state without a completed backup.
218
+ """
219
+
220
+ def _write_backup(data: bytes) -> None:
221
+ stamp = _now_iso_micro()
222
+ suffix = 0
223
+ while True:
224
+ name = (
225
+ f"{state_file.name}.corrupt-{stamp}"
226
+ if suffix == 0
227
+ else f"{state_file.name}.corrupt-{stamp}-{suffix}"
228
+ )
229
+ candidate = state_file.with_name(name)
230
+ try:
231
+ fd = os.open(
232
+ str(candidate), os.O_CREAT | os.O_EXCL | os.O_WRONLY | _NOFOLLOW, 0o600
233
+ )
234
+ except FileExistsError:
235
+ suffix += 1
236
+ continue
237
+ except OSError as exc:
238
+ raise StateError(
239
+ f"could not create corrupt-state backup {candidate}: {exc}"
240
+ ) from exc
241
+ try:
242
+ with os.fdopen(fd, "wb") as handle:
243
+ handle.write(data)
244
+ handle.flush()
245
+ os.fsync(handle.fileno())
246
+ except OSError as exc:
247
+ raise StateError(
248
+ f"could not write corrupt-state backup {candidate}: {exc}"
249
+ ) from exc
250
+ return
251
+
252
+ return _write_backup
253
+
254
+
255
+ def _valid_applied_entry(entry: object) -> bool:
256
+ return (
257
+ isinstance(entry, dict)
258
+ and isinstance(entry.get("rule_id"), str)
259
+ and isinstance(entry.get("target"), str)
260
+ and isinstance(entry.get("applied_at"), str)
261
+ )
262
+
263
+
264
+ def _repair_applied(value: object) -> tuple[list, bool]:
265
+ """(repaired applied list, whether anything was dropped or reset).
266
+
267
+ docs/DESIGN.md 7.5: applied is a list of dicts with string rule_id,
268
+ target, applied_at. If the whole thing is not a list, the namespace is
269
+ reset to []. If it is a list, a malformed entry inside it is dropped;
270
+ every well-formed entry survives, in order.
271
+ """
272
+ if not isinstance(value, list):
273
+ return [], True
274
+ kept = [entry for entry in value if _valid_applied_entry(entry)]
275
+ return kept, len(kept) != len(value)
276
+
277
+
278
+ def _repair_sessions(value: object) -> tuple[dict, bool]:
279
+ """docs/DESIGN.md 7.5: sessions is a dict of dicts. A value under a
280
+ session id that is not itself a dict is dropped; the rest survive."""
281
+ if not isinstance(value, dict):
282
+ return {}, True
283
+ kept = {
284
+ key: val for key, val in value.items() if isinstance(key, str) and isinstance(val, dict)
285
+ }
286
+ return kept, len(kept) != len(value)
287
+
288
+
289
+ def _repair_digest(value: object) -> tuple[dict, bool]:
290
+ """docs/DESIGN.md 7.5: digest is a dict whose pending is a list of
291
+ strings. A non-string entry inside pending is dropped; the rest
292
+ survive."""
293
+ if not isinstance(value, dict):
294
+ return {"pending": []}, True
295
+ pending = value.get("pending")
296
+ if not isinstance(pending, list):
297
+ return {"pending": []}, True
298
+ kept = [item for item in pending if isinstance(item, str)]
299
+ return {"pending": kept}, kept != pending
300
+
301
+
302
+ def _load_and_migrate(state_file: Path, backup: Callable[[bytes], None] | None = None) -> dict:
303
+ """Read state_file and return a well-formed, current-version dict.
304
+
305
+ Never writes state.json itself -- callers decide whether the result
306
+ gets persisted (update()/transaction()) or just handed back
307
+ (load_state()). Raises StateTooNew for a version, or a min_reader,
308
+ this reader does not support, before any migration or repair is even
309
+ considered, and lets an OSError from a read of an existing-but-
310
+ unreadable file propagate (see module docstring).
311
+
312
+ Every other malformed case is repaired in memory. `backup`, when given,
313
+ is called with the file's original bytes exactly once, before any
314
+ repair is applied, whenever a repair is about to happen; it is the
315
+ caller's job to actually persist that backup (see _backup_writer) --
316
+ load_state() passes backup=None and therefore never creates one. If
317
+ `backup` raises, that propagates immediately and no repaired data is
318
+ ever returned: docs/DESIGN.md 7.5's "never reset without a completed
319
+ backup".
320
+ """
321
+ if not state_file.exists():
322
+ return default_state()
323
+
324
+ raw_bytes = state_file.read_bytes()
325
+
326
+ def _reset_whole_file() -> dict:
327
+ if backup is not None:
328
+ backup(raw_bytes)
329
+ return default_state()
330
+
331
+ try:
332
+ text = raw_bytes.decode("utf-8")
333
+ except UnicodeDecodeError:
334
+ return _reset_whole_file()
335
+
336
+ try:
337
+ raw = json.loads(text)
338
+ except (json.JSONDecodeError, RecursionError):
339
+ return _reset_whole_file()
340
+
341
+ if not isinstance(raw, dict):
342
+ return _reset_whole_file()
343
+
344
+ version = raw.get("version")
345
+ if not isinstance(version, int):
346
+ return _reset_whole_file()
347
+
348
+ if version > _VERSION:
349
+ raise StateTooNew(
350
+ f"state.json version {version} is newer than this build of "
351
+ f"agent-postmortem understands (max {_VERSION}); refusing to "
352
+ "read or modify it. Upgrade agent-postmortem."
353
+ )
354
+
355
+ min_reader = raw.get("min_reader")
356
+ if isinstance(min_reader, int) and min_reader > _MIN_READER:
357
+ raise StateTooNew(
358
+ f"state.json min_reader {min_reader} is newer than this build "
359
+ f"of agent-postmortem understands (max {_MIN_READER}); refusing "
360
+ "to read or modify it. Upgrade agent-postmortem."
361
+ )
362
+
363
+ if version < 1:
364
+ return _reset_whole_file()
365
+
366
+ if version == 1:
367
+ applied, changed = _repair_applied(raw.get("applied"))
368
+ if changed and backup is not None:
369
+ backup(raw_bytes)
370
+ return {
371
+ "version": _VERSION,
372
+ "min_reader": _MIN_READER,
373
+ "applied": applied,
374
+ "sessions": {},
375
+ "digest": {"pending": []},
376
+ }
377
+
378
+ # version == 2: keep every well-formed entry in every namespace, drop
379
+ # only what is malformed, and back up the original file once if
380
+ # anything at all needed dropping.
381
+ applied, applied_changed = _repair_applied(raw.get("applied"))
382
+ sessions, sessions_changed = _repair_sessions(raw.get("sessions"))
383
+ digest, digest_changed = _repair_digest(raw.get("digest"))
384
+
385
+ if (applied_changed or sessions_changed or digest_changed) and backup is not None:
386
+ backup(raw_bytes)
387
+
388
+ return {
389
+ "version": _VERSION,
390
+ "min_reader": _MIN_READER,
391
+ "applied": applied,
392
+ "sessions": sessions,
393
+ "digest": digest,
394
+ }
395
+
396
+
397
+ def load_state(path: Path | None = None) -> dict:
398
+ """The current state, migrated if needed. Read-only: see module docstring."""
399
+ state_file = Path(path) if path is not None else state_path()
400
+ return _load_and_migrate(state_file)
401
+
402
+
403
+ def _reject_symlink_or_wrong_kind(path: Path, *, expect_dir: bool, what: str) -> None:
404
+ """lstat path (never following a symlink) and raise StateError if it is
405
+ a symlink, or exists as the wrong kind of thing. Path.mkdir(exist_ok=
406
+ True) and a plain os.open(O_CREAT) both happily accept an existing
407
+ symlink -- mkdir's exist_ok check calls is_dir(), which follows it --
408
+ so this must run first, before either is ever called (docs/DESIGN.md
409
+ 7.5)."""
410
+ try:
411
+ st = path.lstat()
412
+ except FileNotFoundError:
413
+ return # does not exist yet: the caller is about to create it
414
+ except OSError as exc:
415
+ raise StateError(f"could not inspect {what} {path}: {exc}") from exc
416
+ if stat.S_ISLNK(st.st_mode):
417
+ raise StateError(f"refusing to use a symlinked {what}: {path}")
418
+ if expect_dir and not stat.S_ISDIR(st.st_mode):
419
+ raise StateError(f"{what} exists and is not a directory: {path}")
420
+ if not expect_dir and not stat.S_ISREG(st.st_mode):
421
+ raise StateError(f"{what} exists and is not a regular file: {path}")
422
+
423
+
424
+ def _ensure_state_dir(directory: Path) -> None:
425
+ _reject_symlink_or_wrong_kind(directory, expect_dir=True, what="state directory")
426
+ directory.mkdir(parents=True, exist_ok=True)
427
+ try:
428
+ os.chmod(directory, 0o700)
429
+ except OSError:
430
+ pass # best effort: the final mode is verified for real in transaction()
431
+
432
+
433
+ def _open_lock_file(lock_file: Path) -> int:
434
+ """lstat-check then open state.lock with O_NOFOLLOW (where the platform
435
+ supports it) so a symlinked lock file is rejected outright instead of
436
+ silently followed."""
437
+ _reject_symlink_or_wrong_kind(lock_file, expect_dir=False, what="state lock file")
438
+ fd = os.open(str(lock_file), os.O_CREAT | os.O_RDWR | _NOFOLLOW, 0o600)
439
+ try:
440
+ os.chmod(lock_file, 0o600)
441
+ except OSError:
442
+ pass # best effort: the final mode is verified for real in transaction()
443
+ return fd
444
+
445
+
446
+ def _verify_mode(path: Path, expected: int, what: str) -> None:
447
+ """Confirm, by stat -- not by trusting an earlier chmod's return value
448
+ -- that path actually carries the permissions we need. Raises
449
+ StateError, fail closed, when it does not: a filesystem that silently
450
+ ignores chmod must not be mistaken for one that enforced it."""
451
+ try:
452
+ found = stat.S_IMODE(path.stat().st_mode)
453
+ except OSError as exc:
454
+ raise StateError(f"could not verify permissions on {what} {path}: {exc}") from exc
455
+ if found != expected:
456
+ raise StateError(
457
+ f"could not enforce {oct(expected)} permissions on {what} {path} (found {oct(found)})"
458
+ )
459
+
460
+
461
+ def _fsync_dir(dir_path: Path) -> None:
462
+ """Best-effort fsync of a directory's entry, so a rename into it is
463
+ durable and not just sitting in a page cache. Never blocks a write that
464
+ already landed: a directory that cannot be opened or fsynced (some
465
+ filesystems do not support it) is not a reason to raise. Mirrors
466
+ rules.py's own _fsync_dir (duplicated, not imported, to keep rules.py
467
+ the only module that depends on state.py, never the reverse)."""
468
+ try:
469
+ fd = os.open(str(dir_path), os.O_RDONLY)
470
+ except OSError:
471
+ return
472
+ try:
473
+ os.fsync(fd)
474
+ except OSError:
475
+ pass
476
+ finally:
477
+ os.close(fd)
478
+
479
+
480
+ def _write_state(state_file: Path, data: dict) -> None:
481
+ """Write data atomically: a temp file in the same directory, fsynced,
482
+ renamed over state_file, then the containing directory is fsynced too
483
+ so the rename itself is durable. mkstemp already creates the temp file
484
+ mode 0600; chmod after is belt and braces."""
485
+ directory = state_file.parent
486
+ fd, tmp_name = tempfile.mkstemp(dir=str(directory), prefix=".state-", suffix=".json.tmp")
487
+ try:
488
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
489
+ json.dump(data, handle, indent=2, sort_keys=True)
490
+ handle.write("\n")
491
+ handle.flush()
492
+ os.fsync(handle.fileno())
493
+ try:
494
+ os.chmod(tmp_name, 0o600)
495
+ except OSError:
496
+ pass
497
+ os.replace(tmp_name, state_file)
498
+ _fsync_dir(directory)
499
+ except BaseException:
500
+ try:
501
+ os.unlink(tmp_name)
502
+ except OSError:
503
+ pass
504
+ raise
505
+
506
+
507
+ def _is_lock_contention(exc: OSError) -> bool:
508
+ """True only for the errnos that mean "someone else holds this lock
509
+ right now" (BlockingIOError, EAGAIN, EACCES). Anything else (EBADF,
510
+ EINVAL, ...) is a real error, not contention."""
511
+ if isinstance(exc, BlockingIOError):
512
+ return True
513
+ return exc.errno in _CONTENTION_ERRNOS
514
+
515
+
516
+ def _acquire_lock(fd: int) -> None:
517
+ """Exclusive, non-blocking flock, retried in LOCK_POLL_SECONDS steps
518
+ until LOCK_TIMEOUT_SECONDS have elapsed, then StateLocked. Never a
519
+ single blocking flock() call and never a sleep loop longer than the
520
+ timeout: real elapsed time is checked with time.monotonic() every
521
+ iteration, not counted in fixed steps. Only a contention error
522
+ (_is_lock_contention) is retried; any other OSError propagates
523
+ immediately as StateError, never silently retried for the full
524
+ timeout and misreported as StateLocked."""
525
+ deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
526
+ while True:
527
+ try:
528
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
529
+ return
530
+ except OSError as exc:
531
+ if not _is_lock_contention(exc):
532
+ raise StateError(f"could not lock state.lock: {exc}") from exc
533
+ if time.monotonic() >= deadline:
534
+ raise StateLocked(
535
+ f"could not acquire {LOCK_TIMEOUT_SECONDS:.0f}s lock on state.lock; "
536
+ "another agent-postmortem process is writing state"
537
+ ) from None
538
+ time.sleep(LOCK_POLL_SECONDS)
539
+
540
+
541
+ @contextmanager
542
+ def transaction(path: Path | None = None) -> Iterator[dict]:
543
+ """The primitive update() is built on.
544
+
545
+ Acquires state.lock (StateLocked on timeout), loads and migrates the
546
+ current state -- backing up and repairing under the lock as needed --
547
+ and yields the resulting dict for the caller's `with` block to read
548
+ and/or mutate in place. On normal exit from that block the (possibly
549
+ mutated) dict is written back atomically and its final permissions
550
+ verified, still under the lock; if the block raises, none of that runs
551
+ -- state.json is left exactly as it was, and the exception propagates
552
+ after the lock is released. The lock is released, and its fd closed,
553
+ in every case (commit, exception, or a raise from the load/migrate
554
+ step itself).
555
+
556
+ Because the caller's own code runs while the lock is held, a hook can
557
+ make an external write of its own and a matching state record part of
558
+ one transaction, the state lock then serializing concurrent operations
559
+ against that same external target too, not only concurrent state.json
560
+ writers. Nothing in this build does that for a rule any more: rules.
561
+ apply_rule() (docs/DESIGN.md 9) only ever reads a target file, so it
562
+ never calls this function at all.
563
+ """
564
+ state_file = Path(path) if path is not None else state_path()
565
+ directory = state_file.parent
566
+ _ensure_state_dir(directory)
567
+
568
+ lock_file = _lock_path(state_file)
569
+ fd = _open_lock_file(lock_file)
570
+ try:
571
+ _acquire_lock(fd)
572
+ data = _load_and_migrate(state_file, backup=_backup_writer(state_file))
573
+ yield data
574
+ _write_state(state_file, data)
575
+ _verify_mode(directory, 0o700, "state directory")
576
+ _verify_mode(state_file, 0o600, "state file")
577
+ _verify_mode(lock_file, 0o600, "state lock file")
578
+ finally:
579
+ try:
580
+ fcntl.flock(fd, fcntl.LOCK_UN)
581
+ except OSError:
582
+ pass
583
+ os.close(fd)
584
+
585
+
586
+ def update(fn: Callable[[dict], dict | None], path: Path | None = None) -> dict:
587
+ """The only write path for state.json.
588
+
589
+ A thin wrapper around transaction(): calls fn(state) -- which may
590
+ mutate the dict in place, return a replacement dict, or both -- inside
591
+ one transaction and returns the state that was written. Raises
592
+ StateTooNew, propagates an OSError from an unreadable existing file,
593
+ or propagates whatever fn itself raises; in every one of those cases
594
+ nothing is written and the lock is still released.
595
+ """
596
+ with transaction(path) as data:
597
+ result = fn(data)
598
+ if isinstance(result, dict) and result is not data:
599
+ data.clear()
600
+ data.update(result)
601
+ return data
602
+
603
+
604
+ # --- N3a: the disclosure secret and alias registry (docs/DESIGN.md 7.5) -----
605
+ #
606
+ # Two more files live beside state.json in the same directory and share its
607
+ # lock: "secret" (32 random bytes, mode 0600, created once) and
608
+ # "aliases.json" (the alias registry, mode 0600, written under the lock).
609
+ # Neither is state.json: lock_state_dir() below holds state.lock without
610
+ # loading or writing state.json at all, so agent_postmortem.disclosure can
611
+ # read or write the secret and the registry under the same lock that
612
+ # serializes state.json's own writers, without the two ever interleaving
613
+ # unsafely. Every actual filesystem call for either file lives here, never
614
+ # in disclosure.py (docs/DESIGN.md 5.4, tests/test_no_write_surface.py);
615
+ # disclosure.py decides *what* to read or write, this module is the only
616
+ # place that touches a file descriptor to do it.
617
+
618
+
619
+ class Probe(Enum):
620
+ """The file-level outcome of reading the secret or the registry file:
621
+ ABSENT (does not exist), VALID (passed every structural check and was
622
+ read in full), or INVALID (exists but is a symlink, the wrong kind of
623
+ thing, the wrong mode, the wrong owner, the wrong size, or -- for the
624
+ registry -- too large to even read). INVALID and ABSENT are reported
625
+ separately because docs/DESIGN.md 7.5's P6 initialization table treats
626
+ them differently (an absent secret can sometimes be created; an invalid
627
+ one never is)."""
628
+
629
+ ABSENT = "absent"
630
+ VALID = "valid"
631
+ INVALID = "invalid"
632
+
633
+
634
+ _SECRET_SIZE = 32
635
+
636
+
637
+ def _current_uid() -> int:
638
+ """The current process's uid, read through a function so a test can
639
+ monkeypatch it to simulate 'owned by someone else' without actually
640
+ running as another user."""
641
+ return os.getuid()
642
+
643
+
644
+ def secret_path(directory: Path | None = None) -> Path:
645
+ return (directory if directory is not None else state_dir()) / "secret"
646
+
647
+
648
+ def registry_path(directory: Path | None = None) -> Path:
649
+ return (directory if directory is not None else state_dir()) / "aliases.json"
650
+
651
+
652
+ def _dir_fd_capable() -> bool:
653
+ """N3a fix round 3, new Finding 1 (BLOCKER, fixed the robust way): every
654
+ disclosure file operation that runs relative to the verified directory
655
+ descriptor needs os.open, os.unlink, and os.rename (the registry's own
656
+ atomic commit uses os.rename, not os.replace -- see write_registry_
657
+ bytes's docstring for why) to actually honor a dir_fd argument on this
658
+ platform. Checks membership of the real function objects captured at
659
+ import time (_DIR_FD_OPEN/_DIR_FD_UNLINK/_DIR_FD_RENAME), never of
660
+ whatever os.open/os.unlink/os.rename currently resolve to -- see those
661
+ constants' own comment for why. os.supports_dir_fd itself is read live
662
+ (checked at call time, not once at import), so a test can monkeypatch
663
+ that specific attribute to exercise the fail-closed path without
664
+ needing a platform that genuinely lacks it. If this is ever False,
665
+ lock_state_dir() refuses disclosure file access outright (StateError,
666
+ fail closed) rather than silently falling back to a path-based reopen
667
+ that a directory-identity race could defeat (the exact vulnerability
668
+ this fix closes)."""
669
+ return all(
670
+ fn in os.supports_dir_fd for fn in (_DIR_FD_OPEN, _DIR_FD_UNLINK, _DIR_FD_RENAME)
671
+ )
672
+
673
+
674
+ def _open_verified_state_dir(dir_path: Path) -> int:
675
+ """Open the state directory itself with O_NOFOLLOW|O_DIRECTORY and verify
676
+ the open descriptor by fstat: a directory, mode exactly 0700, owned by
677
+ the current uid (N3a fix round 2, Finding 4). Also requires
678
+ _dir_fd_capable(). Returns the open, verified descriptor; raises
679
+ StateError, fail closed, on any mismatch.
680
+
681
+ N3a Codex round 4, MAJOR 1: this runs BEFORE the lock file is opened,
682
+ and state.lock is then opened relative to this descriptor
683
+ (_open_lock_file_at), so the held lock and every disclosure operation
684
+ always name the same directory. Opening the lock by path first and the
685
+ directory second (the earlier order) let a principal able to rename
686
+ entries in the parent swap in a second valid directory in between:
687
+ the lock would belong to one directory and the secret and registry to
688
+ another, defeating serialization. Every later disclosure operation
689
+ runs relative to this descriptor (dir_fd), never a reopen by path."""
690
+ try:
691
+ dir_fd = os.open(str(dir_path), os.O_RDONLY | _O_DIRECTORY | _NOFOLLOW)
692
+ except OSError as exc:
693
+ raise StateError(
694
+ f"could not open state directory {dir_path} for verification: {exc}"
695
+ ) from exc
696
+ try:
697
+ dir_st = os.fstat(dir_fd)
698
+ if not stat.S_ISDIR(dir_st.st_mode):
699
+ raise StateError(f"state directory {dir_path} is not a directory")
700
+ if stat.S_IMODE(dir_st.st_mode) != 0o700:
701
+ raise StateError(
702
+ f"state directory {dir_path} is not mode 0700 (found "
703
+ f"{oct(stat.S_IMODE(dir_st.st_mode))}); refusing disclosure file access"
704
+ )
705
+ if dir_st.st_uid != _current_uid():
706
+ raise StateError(f"state directory {dir_path} is not owned by the current user")
707
+ if not _dir_fd_capable():
708
+ raise StateError(
709
+ "this platform's os module does not support dir_fd for every "
710
+ "operation disclosure files need (os.open/os.unlink/os.rename); "
711
+ "refusing disclosure file access rather than fall back to an "
712
+ "unprotected path-based reopen"
713
+ )
714
+ except BaseException:
715
+ os.close(dir_fd)
716
+ raise
717
+ return dir_fd
718
+
719
+
720
+ def _open_lock_file_at(dir_fd: int) -> int:
721
+ """Open (creating if needed) state.lock relative to the verified state
722
+ directory descriptor, O_NOFOLLOW so a symlink is refused and O_NONBLOCK
723
+ so a FIFO cannot hang the open, mode 0600; repair the mode on the open
724
+ descriptor (best effort; _verify_lock_fd decides). Raises StateError
725
+ if the open itself fails (N3a Codex round 4, MAJOR 1).
726
+
727
+ N3b, main-session finding M1: on macOS APFS, when two processes create
728
+ the same new name relative to a directory descriptor at the same
729
+ moment, the loser's openat(O_CREAT) fails with ENOENT (measured 5 to 15
730
+ of 40 pairs; a path-based open of the same name never did). The
731
+ directory descriptor is held open, so the only other cause of ENOENT
732
+ here is the directory itself having been removed, which fails on every
733
+ attempt. So ENOENT is retried a bounded number of times; any other
734
+ error, or ENOENT on every attempt, raises StateError as before."""
735
+ last_exc: OSError | None = None
736
+ for attempt in range(1, _LOCK_CREATE_ATTEMPTS + 1):
737
+ try:
738
+ fd = os.open(
739
+ "state.lock", os.O_CREAT | os.O_RDWR | os.O_NONBLOCK | _NOFOLLOW, 0o600,
740
+ dir_fd=dir_fd,
741
+ )
742
+ break
743
+ except FileNotFoundError as exc:
744
+ last_exc = exc
745
+ if attempt < _LOCK_CREATE_ATTEMPTS:
746
+ # N3b S14: a short pause (1 ms times the attempt number) so
747
+ # the retries do not depend on a lucky interleaving; the
748
+ # attempt count is unchanged.
749
+ time.sleep(0.001 * attempt)
750
+ except OSError as exc:
751
+ raise StateError(f"could not open state.lock in the state directory: {exc}") from exc
752
+ else:
753
+ raise StateError(
754
+ f"could not open state.lock in the state directory: {last_exc}"
755
+ ) from last_exc
756
+ try:
757
+ os.fchmod(fd, 0o600)
758
+ except OSError:
759
+ pass # best effort: _verify_lock_fd checks the real mode
760
+ return fd
761
+
762
+
763
+ def _verify_lock_fd(lock_fd: int) -> None:
764
+ """fstat the open lock descriptor: a regular file, mode exactly 0600,
765
+ owned by the current uid (N3a fix round 2, Finding 4). Raises
766
+ StateError, fail closed, on any mismatch."""
767
+ lock_st = os.fstat(lock_fd)
768
+ if not stat.S_ISREG(lock_st.st_mode):
769
+ raise StateError("state lock file is not a regular file")
770
+ if stat.S_IMODE(lock_st.st_mode) != 0o600:
771
+ raise StateError(
772
+ f"state lock file is not mode 0600 (found {oct(stat.S_IMODE(lock_st.st_mode))}); "
773
+ "refusing disclosure file access"
774
+ )
775
+ if lock_st.st_uid != _current_uid():
776
+ raise StateError("state lock file is not owned by the current user")
777
+
778
+
779
+ @dataclass(frozen=True)
780
+ class LockedStateDir:
781
+ """What lock_state_dir() yields (N3a fix round 3, new Finding 1): the
782
+ state directory's own path, for building the caller-facing Path values
783
+ secret_path()/registry_path() already return, and the verified
784
+ directory file descriptor every disclosure file operation must run
785
+ relative to for the rest of the lock scope. dir_fd stays valid for
786
+ exactly that scope -- lock_state_dir() closes it on exit -- and must
787
+ never be used, or stored, past the `with` block that produced it."""
788
+
789
+ path: Path
790
+ dir_fd: int
791
+
792
+
793
+ @contextmanager
794
+ def lock_state_dir(directory: Path | None = None) -> Iterator[LockedStateDir]:
795
+ """Hold state.lock -- the same lock, same file, same directory
796
+ transaction() uses -- without loading or writing state.json itself.
797
+
798
+ Used by agent_postmortem.disclosure to read or write the installation
799
+ secret and the alias registry under the same serialization state.json's
800
+ writers get, via the same _ensure_state_dir/_open_lock_file/
801
+ _acquire_lock path (symlink and wrong-kind rejection, 0700 directory,
802
+ 0600 lock file, StateLocked after LOCK_TIMEOUT_SECONDS of contention),
803
+ plus (N3a fix round 2, Finding 4) a stricter fstat-based re-check of
804
+ both before yielding: transaction()'s own best-effort chmod calls are
805
+ not enough of a guarantee for a secret file.
806
+
807
+ N3a fix round 3, new Finding 1 (BLOCKER): yields a LockedStateDir
808
+ carrying both the path and the verified directory descriptor, and keeps
809
+ that descriptor open for the whole lock scope -- it is not reopened or
810
+ re-verified by path again anywhere in this scope. N3a Codex round 4,
811
+ MAJOR 1: the directory is opened and verified first and state.lock is
812
+ opened relative to it, so the held lock always belongs to the
813
+ directory every disclosure operation uses. The lock, and the directory
814
+ descriptor, are both released and closed on every exit, normal or
815
+ raised.
816
+ """
817
+ dir_path = directory if directory is not None else state_dir()
818
+ _ensure_state_dir(dir_path)
819
+ dir_fd = _open_verified_state_dir(dir_path)
820
+ try:
821
+ fd = _open_lock_file_at(dir_fd)
822
+ try:
823
+ _verify_lock_fd(fd)
824
+ _acquire_lock(fd)
825
+ yield LockedStateDir(path=dir_path, dir_fd=dir_fd)
826
+ finally:
827
+ try:
828
+ fcntl.flock(fd, fcntl.LOCK_UN)
829
+ except OSError:
830
+ pass
831
+ os.close(fd)
832
+ finally:
833
+ try:
834
+ os.close(dir_fd)
835
+ except OSError:
836
+ pass
837
+
838
+
839
+ def _dir_relative(path: Path, dir_fd: int | None) -> str:
840
+ """The path argument to actually pass to a dir_fd-aware os.* call: when
841
+ dir_fd is given, every such function silently IGNORES it if path is
842
+ absolute (os.open/os.unlink/os.rename's own documented behavior), so
843
+ this must be path's bare name, never the full joined path, in that
844
+ case -- using the full path would silently fall back to the
845
+ unprotected, non-dir_fd-relative behavior with no error at all. When
846
+ dir_fd is None, this is str(path) unchanged: today's exact behavior,
847
+ for every caller that still addresses a disclosure file by path alone
848
+ (every existing direct, non-locked test in tests/test_disclosure_
849
+ primitives.py and tests/test_state.py)."""
850
+ return path.name if dir_fd is not None else str(path)
851
+
852
+
853
+ def _open_probe_fd(path: Path, *, dir_fd: int | None = None) -> int | None:
854
+ """O_RDONLY|O_NONBLOCK|O_NOFOLLOW open of path, for probing the secret
855
+ or registry file. Returns None for "does not exist" (FileNotFoundError,
856
+ or a non-directory component in the path); raises for every other
857
+ OSError at open time (a real failure, such as permission denied, is not
858
+ one of P5/P4's named violations and must propagate rather than being
859
+ silently folded into "unavailable" -- same principle as the module
860
+ docstring's "genuine filesystem failure is not corruption").
861
+
862
+ O_NONBLOCK matters only for a FIFO: opening one O_RDONLY would otherwise
863
+ block waiting for a writer, and a FIFO in this spot must be rejected
864
+ (wrong kind of thing) instantly, not hang the caller. It has no effect
865
+ on a regular file. A symlink in the final path component raises ELOOP
866
+ with O_NOFOLLOW on every platform this ships for; that is reported the
867
+ same as any other open-time OSError (caller maps it to INVALID) rather
868
+ than being special-cased here, so this function's only two outcomes are
869
+ "doesn't exist" (None) and "opened, or a real error propagates".
870
+
871
+ N3a fix round 3, new Finding 1: dir_fd, when given, makes this open run
872
+ relative to that verified directory descriptor (see _dir_relative)
873
+ instead of by path -- see lock_state_dir/LockedStateDir.
874
+ """
875
+ try:
876
+ return os.open(
877
+ _dir_relative(path, dir_fd),
878
+ os.O_RDONLY | os.O_NONBLOCK | _NOFOLLOW,
879
+ dir_fd=dir_fd,
880
+ )
881
+ except FileNotFoundError:
882
+ return None
883
+ except NotADirectoryError:
884
+ return None
885
+
886
+
887
+ def probe_secret(path: Path, *, dir_fd: int | None = None) -> tuple[Probe, bytes | None]:
888
+ """(Probe.ABSENT, None) if path does not exist. (Probe.INVALID, None)
889
+ for a symlink, a non-regular file (directory, FIFO, ...), a mode other
890
+ than exactly 0600, an owner other than the current uid (_current_uid),
891
+ or a size other than exactly 32 bytes -- every check made on the
892
+ already-opened descriptor's fstat, never a separate stat() call that
893
+ could race a swapped-in symlink. (Probe.VALID, <32 bytes>) otherwise.
894
+ docs/DESIGN.md 7.5/P5: any of the INVALID cases "makes the secret
895
+ unavailable"; this function only classifies, it never repairs or
896
+ removes anything. dir_fd: see _open_probe_fd.
897
+ """
898
+ try:
899
+ fd = _open_probe_fd(path, dir_fd=dir_fd)
900
+ except OSError as exc:
901
+ if _NOFOLLOW and exc.errno == errno.ELOOP:
902
+ return (Probe.INVALID, None)
903
+ raise
904
+ if fd is None:
905
+ return (Probe.ABSENT, None)
906
+ try:
907
+ st = os.fstat(fd)
908
+ if not stat.S_ISREG(st.st_mode):
909
+ return (Probe.INVALID, None)
910
+ if stat.S_IMODE(st.st_mode) != 0o600:
911
+ return (Probe.INVALID, None)
912
+ if st.st_uid != _current_uid():
913
+ return (Probe.INVALID, None)
914
+ if st.st_size != _SECRET_SIZE:
915
+ return (Probe.INVALID, None)
916
+ data = os.read(fd, _SECRET_SIZE + 1)
917
+ if len(data) != _SECRET_SIZE:
918
+ return (Probe.INVALID, None)
919
+ return (Probe.VALID, data)
920
+ finally:
921
+ os.close(fd)
922
+
923
+
924
+ def _fsync_dir_strict(dir_path: Path, *, dir_fd: int | None = None) -> None:
925
+ """Like _fsync_dir, but raises OSError instead of swallowing one.
926
+
927
+ Used only for the disclosure secret and alias registry (docs/
928
+ DESIGN.md 7.5a, N3a fix round 2, Finding 1): a durability failure for
929
+ either of those two files must be reported and recovered from
930
+ (agent_postmortem.disclosure re-probes and either returns the
931
+ committed result or falls back), never silently accepted the way
932
+ state.json's own best-effort _fsync_dir is. state.json keeps using
933
+ _fsync_dir unchanged -- this is a stricter sibling, not a replacement.
934
+
935
+ N3a fix round 3, new Finding 1: when dir_fd is given, it IS the
936
+ already-open, already-verified state directory descriptor itself
937
+ (lock_state_dir/LockedStateDir) -- fsync it directly rather than
938
+ reopening the directory by path a second time."""
939
+ if dir_fd is not None:
940
+ os.fsync(dir_fd)
941
+ return
942
+ fd = os.open(str(dir_path), os.O_RDONLY)
943
+ try:
944
+ os.fsync(fd)
945
+ finally:
946
+ os.close(fd)
947
+
948
+
949
+ def fsync_directory(directory: Path, *, dir_fd: int | None = None) -> None:
950
+ """Public name for _fsync_dir_strict, for disclosure.py's P6
951
+ initialization: after create_secret() the state directory is fsynced
952
+ so the secret's directory entry is durable, whether or not a registry
953
+ write follows. Raises OSError on failure (N3a fix round 2, Finding 1):
954
+ by the time this runs the secret (and, on a cold start, the registry)
955
+ already exist, fully written and verified, so the caller must treat a
956
+ failure here as durability-only and re-probe rather than assuming
957
+ nothing changed. dir_fd: see _fsync_dir_strict."""
958
+ _fsync_dir_strict(directory, dir_fd=dir_fd)
959
+
960
+
961
+ def _verify_fd(fd: int, *, expect_regular: bool, expected_mode: int, what: str) -> None:
962
+ """fstat the already-open descriptor and require it is a regular file
963
+ (when expect_regular), mode exactly expected_mode, and owned by the
964
+ current uid. Raises StateError on the open descriptor, before the
965
+ caller's commit point (docs/DESIGN.md 7.5a, N3a fix round 2, Finding 1
966
+ and Finding 11): every check that can fail runs here, on the fd itself,
967
+ never via a later, separate stat() of the path that could observe a
968
+ swapped file."""
969
+ st = os.fstat(fd)
970
+ if expect_regular and not stat.S_ISREG(st.st_mode):
971
+ raise StateError(f"{what}: the open descriptor is not a regular file")
972
+ if stat.S_IMODE(st.st_mode) != expected_mode:
973
+ raise StateError(
974
+ f"{what}: wrong mode on the open descriptor (found "
975
+ f"{oct(stat.S_IMODE(st.st_mode))}, expected {oct(expected_mode)})"
976
+ )
977
+ if st.st_uid != _current_uid():
978
+ raise StateError(f"{what}: the open descriptor is not owned by the current user")
979
+
980
+
981
+ def _write_full(fd: int, data: bytes, *, what: str) -> None:
982
+ """Write every byte of data to fd, looping until done.
983
+
984
+ POSIX permits os.write() to return fewer bytes than requested without
985
+ raising (N3a fix round 2, Finding 2): a single unchecked os.write()
986
+ call can silently leave a short file that is reported as complete. A
987
+ write that makes no progress at all (a zero-length return with data
988
+ still remaining) is treated as an error rather than retried forever.
989
+ """
990
+ total = len(data)
991
+ written = 0
992
+ view = memoryview(data)
993
+ while written < total:
994
+ n = os.write(fd, view[written:])
995
+ if n <= 0:
996
+ raise StateError(
997
+ f"{what}: os.write() returned {n} with {total - written} of "
998
+ f"{total} bytes remaining"
999
+ )
1000
+ written += n
1001
+
1002
+
1003
+ def _verify_fd_size(fd: int, expected_size: int, *, what: str) -> None:
1004
+ """Confirm, by fstat on the still-open descriptor, that exactly
1005
+ expected_size bytes actually landed (N3a fix round 2, Finding 2): the
1006
+ write loop above only proves os.write() reported writing every byte,
1007
+ not that the file's own size agrees."""
1008
+ st = os.fstat(fd)
1009
+ if st.st_size != expected_size:
1010
+ raise StateError(
1011
+ f"{what}: wrote {expected_size} bytes but the descriptor now reports "
1012
+ f"size {st.st_size}"
1013
+ )
1014
+
1015
+
1016
+ def create_secret(path: Path, *, dir_fd: int | None = None) -> bytes:
1017
+ """Create the installation secret: secrets.token_bytes(32), written to a
1018
+ brand-new file at path with O_CREAT|O_EXCL|O_WRONLY|O_NOFOLLOW. Every
1019
+ check that can fail -- regular file, mode exactly 0600, owned by the
1020
+ current uid, then (after the write loop and fsync) size exactly 32 --
1021
+ runs on the open descriptor, before the fd is ever closed (docs/
1022
+ DESIGN.md 7.5a, N3a fix round 2, Finding 1): once this function
1023
+ returns, the secret is fully committed, and the only thing that can
1024
+ still fail is the caller's own, separate, durability-only directory
1025
+ fsync (fsync_directory). Raises FileExistsError if path already exists
1026
+ (the caller must have already probed ABSENT, under the lock, before
1027
+ calling this) and StateError/OSError for any other failure.
1028
+ Never repairs, rewrites, or replaces an existing secret (docs/DESIGN.md
1029
+ 7.5/P5): the exclusive create is what enforces that. A failure before
1030
+ this function returns unlinks the file it just created rather than
1031
+ leaving a present-but-unverified secret behind: the observable state
1032
+ after any failure here is deterministically "absent" (one of the two
1033
+ states P6's table actually reasons about), never a third, undocumented
1034
+ state that happens to read back as VALID anyway.
1035
+
1036
+ dir_fd: see _open_probe_fd/_dir_relative.
1037
+ """
1038
+ data = secrets.token_bytes(_SECRET_SIZE)
1039
+ fd = os.open(
1040
+ _dir_relative(path, dir_fd),
1041
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY | _NOFOLLOW,
1042
+ 0o600,
1043
+ dir_fd=dir_fd,
1044
+ )
1045
+ try:
1046
+ try:
1047
+ _verify_fd(fd, expect_regular=True, expected_mode=0o600, what="disclosure secret")
1048
+ _write_full(fd, data, what="disclosure secret")
1049
+ os.fsync(fd)
1050
+ _verify_fd_size(fd, _SECRET_SIZE, what="disclosure secret")
1051
+ finally:
1052
+ os.close(fd)
1053
+ except BaseException:
1054
+ try:
1055
+ os.unlink(_dir_relative(path, dir_fd), dir_fd=dir_fd)
1056
+ except OSError:
1057
+ pass
1058
+ raise
1059
+ return data
1060
+
1061
+
1062
+ def probe_registry_bytes(
1063
+ path: Path, *, max_bytes: int, dir_fd: int | None = None
1064
+ ) -> tuple[Probe, bytes | None]:
1065
+ """File-level probe of the alias registry, exactly like probe_secret:
1066
+ ABSENT if missing; INVALID for a symlink, a non-regular file, a mode
1067
+ other than 0600, an owner other than the current uid, or a size over
1068
+ max_bytes; VALID with the raw bytes otherwise. JSON parsing and P4's
1069
+ schema validation are disclosure.py's job, not this function's -- this
1070
+ layer only establishes that the bytes came from a plain,
1071
+ correctly-permissioned, small-enough file in the first place. dir_fd:
1072
+ see _open_probe_fd.
1073
+ """
1074
+ try:
1075
+ fd = _open_probe_fd(path, dir_fd=dir_fd)
1076
+ except OSError as exc:
1077
+ if _NOFOLLOW and exc.errno == errno.ELOOP:
1078
+ return (Probe.INVALID, None)
1079
+ raise
1080
+ if fd is None:
1081
+ return (Probe.ABSENT, None)
1082
+ try:
1083
+ st = os.fstat(fd)
1084
+ if not stat.S_ISREG(st.st_mode):
1085
+ return (Probe.INVALID, None)
1086
+ if stat.S_IMODE(st.st_mode) != 0o600:
1087
+ return (Probe.INVALID, None)
1088
+ if st.st_uid != _current_uid():
1089
+ return (Probe.INVALID, None)
1090
+ if st.st_size > max_bytes:
1091
+ return (Probe.INVALID, None)
1092
+ chunks = []
1093
+ remaining = st.st_size
1094
+ while remaining > 0:
1095
+ chunk = os.read(fd, remaining)
1096
+ if not chunk:
1097
+ break
1098
+ chunks.append(chunk)
1099
+ remaining -= len(chunk)
1100
+ data = b"".join(chunks)
1101
+ if len(data) != st.st_size:
1102
+ return (Probe.INVALID, None)
1103
+ return (Probe.VALID, data)
1104
+ finally:
1105
+ os.close(fd)
1106
+
1107
+
1108
+ def _create_exclusive_temp_file(
1109
+ directory: Path, prefix: str, *, dir_fd: int | None = None
1110
+ ) -> tuple[int, Path]:
1111
+ """O_CREAT|O_EXCL|O_WRONLY|O_NOFOLLOW, mode 0600, retried with a
1112
+ numeric suffix on FileExistsError -- never check-then-create (same
1113
+ pattern as _backup_writer above). Returns (fd, candidate), candidate
1114
+ always the full directory-joined Path (for reporting/consistency); the
1115
+ caller uses candidate.name, not candidate, for any later dir_fd-relative
1116
+ operation on it (see _dir_relative). dir_fd: when given, the open
1117
+ itself runs relative to it, by the bare candidate name alone -- never
1118
+ the joined path, which would silently ignore dir_fd."""
1119
+ suffix = 0
1120
+ while True:
1121
+ name = f"{prefix}-{os.getpid()}" if suffix == 0 else f"{prefix}-{os.getpid()}-{suffix}"
1122
+ candidate = directory / name
1123
+ try:
1124
+ fd = os.open(
1125
+ name if dir_fd is not None else str(candidate),
1126
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY | _NOFOLLOW,
1127
+ 0o600,
1128
+ dir_fd=dir_fd,
1129
+ )
1130
+ except FileExistsError:
1131
+ suffix += 1
1132
+ continue
1133
+ return fd, candidate
1134
+
1135
+
1136
+ def _verify_registry_after_commit(
1137
+ path: Path, expected: bytes, *, dir_fd: int | None = None
1138
+ ) -> None:
1139
+ """N3a fix round 2 (continued), new Finding 6 (MINOR): the post-commit
1140
+ check right after write_registry_bytes's own os.replace() must not
1141
+ trust a separate, symlink-following stat() of the path -- the same
1142
+ class of TOCTOU _verify_fd already closes for the *pre*-commit
1143
+ descriptor checks, but _verify_mode (state.json's own helper, by path)
1144
+ does not close for this specific caller: a same-principal race right
1145
+ after the rename could swap aliases.json for a symlink to some other,
1146
+ legitimately mode-0600 file that would pass a plain stat(). Reopen the
1147
+ final path with O_NOFOLLOW, then verify everything that can fail --
1148
+ regular file, mode exactly 0600, owned by the current uid, and that the
1149
+ bytes actually on disk are the exact bytes this call just wrote -- from
1150
+ that one freshly opened descriptor. This is a disclosure-only sibling
1151
+ of _verify_mode; state.json's own writer keeps using _verify_mode
1152
+ unchanged, since that caller's own prerequisite checks (docs/
1153
+ DESIGN.md 7.5a) and threat model are different.
1154
+
1155
+ N3a fix round 3, new Finding 2 (MAJOR): the open also carries
1156
+ O_NONBLOCK, for the same reason _open_probe_fd already does -- without
1157
+ it, a FIFO swapped in at the final path right after os.replace() would
1158
+ block this open indefinitely waiting for a writer that is never coming,
1159
+ while state.lock is held. O_NONBLOCK has no effect on a regular file,
1160
+ so the normal case is unchanged; a FIFO instead opens immediately and
1161
+ is rejected by the regular-file check below.
1162
+
1163
+ dir_fd: see _open_probe_fd/_dir_relative.
1164
+ """
1165
+ try:
1166
+ fd = os.open(
1167
+ _dir_relative(path, dir_fd), os.O_RDONLY | os.O_NONBLOCK | _NOFOLLOW, dir_fd=dir_fd
1168
+ )
1169
+ except OSError as exc:
1170
+ raise StateError(
1171
+ f"could not reopen alias registry {path} for post-commit verification: {exc}"
1172
+ ) from exc
1173
+ try:
1174
+ st = os.fstat(fd)
1175
+ if not stat.S_ISREG(st.st_mode):
1176
+ raise StateError(f"alias registry {path} is not a regular file after commit")
1177
+ if stat.S_IMODE(st.st_mode) != 0o600:
1178
+ raise StateError(
1179
+ f"alias registry {path} is not mode 0600 after commit (found "
1180
+ f"{oct(stat.S_IMODE(st.st_mode))})"
1181
+ )
1182
+ if st.st_uid != _current_uid():
1183
+ raise StateError(f"alias registry {path} is not owned by the current user after commit")
1184
+ read_back = os.read(fd, len(expected) + 1)
1185
+ if read_back != expected:
1186
+ raise StateError(f"alias registry {path} content does not match what was just written")
1187
+ finally:
1188
+ os.close(fd)
1189
+
1190
+
1191
+ def write_registry_bytes(path: Path, data: bytes, *, dir_fd: int | None = None) -> None:
1192
+ """Atomically write data (the alias registry's own encoded JSON bytes,
1193
+ already bound-checked by the caller) to path: a fresh temp file in the
1194
+ same directory, every check that can fail (regular file, mode exactly
1195
+ 0600, owned by the current uid, then size) run on the open descriptor,
1196
+ fsync, then an atomic rename over path. That rename is this write's
1197
+ commit point (docs/DESIGN.md 7.5a, N3a fix round 2, Finding 1): a
1198
+ failure before it unlinks the temp file and leaves path untouched,
1199
+ exactly as before. A failure after it (the strict directory fsync, or
1200
+ the final post-commit reverification, N3a fix round 2 continued,
1201
+ Finding 6: reopened with O_NOFOLLOW rather than a separate,
1202
+ symlink-following stat()) raises PostCommitVerificationError instead --
1203
+ path already holds the new content, so the caller must re-probe and
1204
+ decide, never assume nothing changed.
1205
+
1206
+ dir_fd (N3a fix round 3, new Finding 1): when given, every operation
1207
+ here -- the temp file's own create, its rename over path, and the
1208
+ directory fsync -- runs relative to that verified directory descriptor
1209
+ instead of by path. The commit-point rename uses os.rename(...,
1210
+ src_dir_fd=dir_fd, dst_dir_fd=dir_fd) rather than os.replace(): POSIX's
1211
+ rename() is already an atomic overwrite (the same guarantee os.replace
1212
+ gives on every platform this ships for, since fcntl/flock already make
1213
+ this module POSIX-only), but os.replace's own dir_fd support is not
1214
+ reliably reported by os.supports_dir_fd even on a platform where it
1215
+ actually works, so the dir_fd-relative commit uses os.rename, the
1216
+ function os.supports_dir_fd actually lists (see _dir_fd_capable). When
1217
+ dir_fd is None, every call below is unchanged from before this fix:
1218
+ the same path-based os.replace(), for every existing direct,
1219
+ non-locked caller.
1220
+ """
1221
+ directory = path.parent
1222
+ fd, tmp_path = _create_exclusive_temp_file(directory, ".aliases.json.tmp", dir_fd=dir_fd)
1223
+ try:
1224
+ try:
1225
+ _verify_fd(
1226
+ fd, expect_regular=True, expected_mode=0o600, what="alias registry temp file"
1227
+ )
1228
+ _write_full(fd, data, what="alias registry")
1229
+ os.fsync(fd)
1230
+ _verify_fd_size(fd, len(data), what="alias registry")
1231
+ finally:
1232
+ os.close(fd)
1233
+ except BaseException:
1234
+ try:
1235
+ os.unlink(_dir_relative(tmp_path, dir_fd), dir_fd=dir_fd)
1236
+ except OSError:
1237
+ pass
1238
+ raise
1239
+
1240
+ try:
1241
+ if dir_fd is not None:
1242
+ os.rename(tmp_path.name, path.name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
1243
+ else:
1244
+ os.replace(str(tmp_path), str(path))
1245
+ except OSError:
1246
+ try:
1247
+ os.unlink(_dir_relative(tmp_path, dir_fd), dir_fd=dir_fd)
1248
+ except OSError:
1249
+ pass
1250
+ raise
1251
+
1252
+ # COMMIT POINT PASSED: `path` now holds the new content. A failure from
1253
+ # here on is durability/verification only and must never be reported
1254
+ # the same way as a failure before this point.
1255
+ try:
1256
+ _fsync_dir_strict(directory, dir_fd=dir_fd)
1257
+ _verify_registry_after_commit(path, data, dir_fd=dir_fd)
1258
+ except (OSError, StateError) as exc:
1259
+ raise PostCommitVerificationError(
1260
+ f"alias registry write committed but could not be verified durable: {exc}"
1261
+ ) from exc