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,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
|