* fix(email): guard user-supplied IMAP/SMTP hosts against SSRF
POST /api/email/accounts/test opened raw imaplib/smtplib connections to
whatever host:port the request body named, and POST/PUT /api/email/accounts
and PUT /api/email/config persisted arbitrary hosts for the background
pollers to contact. All of these sit behind require_user/require_owner with
no admin gate, so any authenticated user could make the server connect to
loopback, LAN and cloud-metadata targets (169.254.169.254) and read the
first 200 characters of the remote banner back out of the friendly error.
THREAT_MODEL.md lists email as admin-only; the tool layer honours that but
the HTTP routes did not.
Add check_outbound_host() to src/url_safety.py, a bare-host companion to
check_outbound_url() with the same classification (link-local, multicast,
reserved and unspecified always rejected; private/loopback only with
block_private) plus localhost-name handling and an explicit policy for names
that do not resolve. Wire it into the four email routes via
_check_mail_host(): non-admin owners (and everyone when
EMAIL_BLOCK_PRIVATE_IPS=true) also lose private/loopback targets, admins
keep the local-first LAN/Dovecot setups. Unresolvable names pass through
because the connect that follows fails with the same DNS error.
Regression tests cover the helper's policy table and each route: the test
route rejects before any socket opens, save routes reject before touching
the DB, admins keep loopback unless locked down, and an update that fails
validation leaves the stored row untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwcwfY2uQMxNRsK3y6xBAp
* fix(email): harden IMAP/SMTP SSRF protection against rebinding, TOCTOU, and unvetted hosts
- P0: Implement safe_create_connection() in src/url_safety.py to resolve hostnames once, validate every returned address against SSRF policy, and connect directly to the pinned IP. This eliminates DNS rebinding and TOCTOU while preserving TLS SNI and certificate hostname verification across IMAP, IMAPS, STARTTLS, SMTP, and SMTP_SSL.
- P1: Ensure unresolvable or deferred hostnames cannot become an unrestricted future connection capability by enforcing runtime SSRF validation fail-closed on every connection, while maintaining compatibility with offline configurations.
- P2: Extend SSRF enforcement to the core connection layer (_open_imap_connection, _send_smtp_message, _imap_connect) using ContextVar owner scoping, protecting pre-existing DB rows, restored configs, background pollers, and scheduled operations.
- P3: Allow admin and single-user access to IPv6 loopback (::1, [::1]) for local mail servers (e.g. Dovecot) by checking loopback before is_reserved, while keeping link-local, cloud metadata (169.254.169.254, 100.100.100.200), and non-admin restrictions strictly enforced.
- Add comprehensive regression and adversarial test coverage in tests/test_email_account_host_ssrf.py.
* fix(email): reject unresolved saved hosts without stdlib monkeypatches
Use the existing explicit policy IMAP/SMTP classes and canonical connect_outbound_tcp helper. Remove process-wide stdlib assignments, context-variable plumbing, redundant aliases and resolver-signature retries. Reject unsafe or unresolved hosts before account/config persistence, keep DNS work off the event loop, return fixed client errors, identify Alibaba metadata accurately and reject invalid TCP ports. Validation: 654 relevant email, URL-safety and CodeQL regression tests passed, 3 skipped. Three unrelated browser checks were excluded after environment failures (missing Playwright browser and Chromium timeout). Running-app checks verified rejected create/update/config requests leave stored data unchanged, administrator LAN configuration still works, and metadata/non-admin loopback are blocked.
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: PR #6029 Reviewer <reviewer@odysseus-review.local>
Co-authored-by: Afonso Junior <chatgpt@yack.com.br>
* fix(security): block read-only cookbook tools for non-admin owners
list_cached_models, list_served_models, tail_serve_output, list_downloads,
search_hf_models, list_serve_presets and list_cookbook_servers were in the
plan-mode read-only allowlist but in neither NON_ADMIN_BLOCKED_TOOLS nor the
dispatcher's _ADMIN_TOOLS. Each implementation calls a require_admin route
over the internal loopback with the process-wide INTERNAL_TOOL_TOKEN and
never checks the owner, so a non-admin user's agent inherited admin reach
through them. list_cached_models is the worst case: a caller-chosen `host`
is forwarded to /api/model/cached, which SSHes to that host with the app
user's keys and walks caller-chosen model_dirs. THREAT_MODEL.md lists model
serving as admin-only, so gate the read side the same way as serve/download.
Adding the names to NON_ADMIN_BLOCKED_TOOLS also hides them from non-admin
tool schemas and caps bearer-token agent runs; _ADMIN_TOOLS blocks them at
dispatch. Regression test covers both lists, schema hiding, and that the
loopback is never reached for a non-admin owner.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwcwfY2uQMxNRsK3y6xBAp
* fix(cookbook): bind request authority in admin gate regressions
Keep all seven read-only Cookbook tools in the admin prompt inventory. Grant request authority in dispatch tests so the admin gate itself is exercised, and verify administrators still reach each handler. Validation: 359 focused authorization, tool-policy and schema tests passed. Against the running app with real auth accounts, all seven tools reject a non-admin and an admin reaches the authenticated Cookbook HTTP API.
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Afonso Junior <chatgpt@yack.com.br>
* fix(agent): give file tools the agent workspace as root when no workspace is set (#6679)
With no workspace bound, bash/python run in data/agent_workspace, but
RequestAuthority sealed no filesystem root, so every native file tool
failed with "requires a sealed resource root", and an approved
write_file failed with "no sealed resource identity". Seal the agent
workspace instead, and resolve relative paths in _resolve_tool_path
against it rather than the server's cwd.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: re-seal resource roots when a test swaps the authority owner (#6679)
A no-workspace authority now seals the agent workspace for its owner, so
replace(authority, owner="bob") copied alice's root and was rejected
before the approval-binding check the tests are about.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(agent): only seal a vetted agent workspace as the file-tool root (#6679)
Apply the agent_cwd() check before sealing data/agent_workspace: it must
be a real directory in the readable data subdirs, otherwise no root is
sealed. A symlinked workspace no longer redirects the file tools or the
bash/python launch scope. Tighten the dispatch test assertions to the
exact 'outside the sealed roots' message.
* refactor(agent): vet the no-workspace file root with default_agent_workspace (#6679)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#6683 made an exact approval seal a launch boundary (and a filesystem root)
in the request's workspace, but only when a workspace is bound. Agent mode
with no folder picked is the default, and there the approval still sealed
nothing, so "Allow" always ended in "Approved action has no sealed
process/job identity" (bash/python) or "Approved filesystem action has no
sealed resource identity" (write_file). In Docker every agent turn carries
the backend runtime context, so bash and write_file always ask for approval
and never ran.
With no workspace, seal the root in the agent workspace, which is where
agent_cwd() already runs bash/python in that case. Inherited (child)
authorities still cannot widen.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The manage_endpoints agent tool accepts arbitrary user-supplied base_urls
and writes them to the database without calling check_outbound_url(),
while the HTTP route (POST /api/model-endpoints) enforces full SSRF
hardening. A prompt-injection payload smuggled into a skill, note, fetched
page, or email could instruct the model to register a cloud-metadata or
internal-network endpoint via this tool path.
Add the same check_outbound_url() guard used by the HTTP route to the
agent tool's 'add' action. Also sanitize the error message in the except
block to avoid leaking raw exception text.
Add 8 regression tests covering: cloud metadata, non-HTTP schemes,
loopback (local-first), strict mode, safe URLs, empty URLs, gopher
scheme, and error message sanitization.
Co-authored-by: isharak7m <192635824+isharak7m@users.noreply.github.com>
Catch OverflowError in addition to TypeError and ValueError when converting
research_max_tokens, research_extraction_timeout_seconds, and
research_extraction_concurrency to int. Non-finite numbers parsed from JSON
(such as 1e309 or -1e309 representing float infinity) raise OverflowError on
int() conversion, which previously escaped the handler and crashed scheduled
research execution. Also add unit tests covering exponent overflow values.
POST /api/tasks and the scheduler both refuse action tasks whose action
is in ADMIN_ONLY_TASK_ACTIONS (run_local, run_script, ssh_command,
cookbook_serve) for owners without admin task privileges: those actions
reach subprocess.run(shell=True) or SSH with no sandbox. The model-facing
manage_tasks tool applied neither check, so an agent could create or edit
such a task, resume it, or trigger it, and only the scheduler's
execution-time guard stood in the way (pausing the task after the fact).
Route the tool through the same src.task_action_policy helpers the HTTP
route uses: create checks the requested type/action, edit checks the task
as it would be after the edit (so an llm task cannot be flipped into a
shell action), and resume/run check the stored task. Admins, non-
privileged actions and llm/research tasks are unaffected; pause still
works for everyone since it only narrows.
Fixes#6021
Claude-Session: https://claude.ai/code/session_01UAB5sz5srMKR7rbeDFtdMH
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
* fix(security): deny generated-image access when ownership verification fails
The except Exception block in serve_generated_image catches all
exceptions during ownership verification and falls through to serve
the file anyway. If the database is down or corrupted, the ownership
check is bypassed entirely — any authenticated user can download any
generated image by guessing the content-hash filename.
Raise HTTP 503 (Service Unavailable) instead of falling through,
so the endpoint fails closed when verification is impossible. This
matches the defense-in-depth principle: deny access on verification
errors rather than granting it.
* fix(security): enforce fail-closed ownership and secure caching for generated images
- Fail closed on missing gallery records, null/empty owners, and DB errors
- Support both cookie session and bearer API token owners via effective_user
- Add in-memory registry for freshly generated images to preserve access
- Eliminate file existence oracle by verifying ownership before disk probing
- Update Cache-Control to private, no-cache, must-revalidate
- Pass owner attribution through tool execution and image MCP server
- Add comprehensive hermetic regression tests for ownership gates and caching
* fix(security): sanitize tool owner arguments and drop unneeded MCP env fallback
- Remove redundant and unsafe ODYSSEUS_MCP_IMAGE_OWNER fallback in image_gen_server.py to fix 4 tests in test_env_reference.py.
- Sanitize caller-supplied tool arguments across all MCP and email tool dispatch paths in src/tool_execution.py: strip untrusted _odysseus_* keys from arguments.
- Only inject _odysseus_owner when verified trusted server context is present, preventing untrusted/anonymous users from spoofing ownership claims.
- Sanitize empty/whitespace owner strings in image_gen_server.py to None.
- Add comprehensive regression tests in test_generated_image_ownership.py for argument sanitization, owner injection, fallback propagation, and server sanitization.
---------
Co-authored-by: isharak7m <192635824+isharak7m@users.noreply.github.com>
Co-authored-by: PR #6166 Reviewer <reviewer@odysseus-review.local>
Skills tagged remote-only in frontmatter are excluded from the injected
skill index when the active model's endpoint is local (loopback/private/
tailscale per is_local_endpoint). Big on-demand reference lookups
overwhelm small local contexts; local models never see the skill, while
remote models and API/UI listings are unaffected.
- SkillsManager.index_for gains optional keyword-only local_model flag
(None = don't gate, same contract as active_toolsets=None)
- _build_system_prompt/_build_base_prompt thread endpoint_url from
_build_route_request_state; unknown endpoint fails open
- tests: cover hidden-on-local, visible-on-remote/unknown, untagged
skills never gated
Refs: discussed for the local-model overwhelm problem
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
With Auto-approve OFF the agent loop asked get_relevant_skills for
min_confidence=2.0, meant as 'published only'. The gate applies the
confidence check to published skills too, and confidence is 0-1, so no
user skill was ever injected, published or not. The explicit-activation
path already treats OFF as status == published.
Add a published_only flag to get_relevant_skills and pass it from both
callers that used the sentinel (relevance injection and the skill
preflight). Auto-approve ON is unchanged: a skill still needs a passing
audit and the confidence threshold.
Fixes#6634
* feat(settings): opt-in reasoning effort for utility model calls
Add utility_reasoning_effort (empty = provider default) and let utility
calls inherit a chat session's effort only on that session's matching
endpoint+model route. Explicit caller effort wins; otherwise the utility
setting wins over session inheritance on utility-chain routes. Resolve
the chain and setting per user and validate levels against model evidence.
No-session work uses the setting only, with no chat-effort crossover.
- src/utility_effort.py owns effort_for_call and candidate_effort_factory
- remove global effort fill from llm_call/llm_call_async; utility call
sites pass effort explicitly, so unrelated calls on the same route do
not pick it up
- wire auto-title, memory/persona/skill extraction, memory suggest/import/
audit, compaction, auto-sort, and per-owner background task candidates
- wire no-session email writing-style, summary, and translation calls,
plus skills evaluation, necessity/retrieval audits, and improvement
- deliberately exclude email replies because thinking_mode='off'
- add the optional candidate_request_factory to llm_call_async_with_fallback
- add sync llm_call reasoning_effort and include it in the cache key
- show the Utility Model effort selector only for advertised levels
Fixes#6569
* fix(utility): exclude email replies and support inherited effort in settings
Pass reasoning_effort='' to task_llm_call_async for scheduled reply drafting to honor the email reply exclusion. In AI settings, resolve the effective Default Chat endpoint and model when Utility is set to 'Same as chat' so supported reasoning effort levels can be selected.
* feat(utility): support same-as-chat effort and session model fallback in compaction
In AI settings, show 'Same as chat' as the default effort option when Utility Model is inherited, and support an explicit 'Provider default' (off) override. Pass the active session fallback in context compaction callers so compaction shares the live chat session's model and reasoning effort for maximum prompt/KV cache affinity.
* fix(settings): clarify chat effort inheritance and keep option visible
Label inherited effort as 'Same as chat (when supported)' and mention
cache affinity and latency speedups in the help text. Ensure the effort
control remains visible when the utility model is set to 'Same as chat'
even if the default chat model lacks advertised reasoning levels, and
populate candidate levels across configured endpoints so the user can
configure effort or keep provider default.
* feat(settings): per-fallback reasoning effort for utility model
Each utility fallback row gets an Effort select, shown only when the
selected model advertises reasoning levels. The choice is stored as
reasoning_effort on the utility_model_fallbacks entry and sent only to
that candidate; an empty value means provider default.
* fix(utility): transmit reasoning effort in non-streaming calls
---------
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
ToolApprovalStore.create() sealed a process identity only from the request
authority's launch scopes, which exist only for granted bash/python. For an
ungranted call the sealing failed silently, so the approval was created
without a process identity and the executor always refused it ("Approved
action has no sealed process/job identity"). When the authority is not
inherited and has no launch scope for the backend, seal one in the
request's single workspace root at approval time, as filesystem approvals
already do. Inherited authorities still cannot widen.
The skill test run and audit arm left their `async for` over
stream_agent_loop early when an approval was needed, without closing the
generator. asyncio closed it later from another task, so its ContextVar
resets raised "Token ... was created in a different Context". Close the
stream with aclosing, like the scheduler and teacher escalation do.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat: add Notes and Todos as agent chat context
- Send selected Note/Todo IDs from the Notes UI
- Load selected notes with owner scoping in chat routes
- Format note content and checklist items for the model
- Inject selected notes as untrusted chat context
- Add a selected-context indicator beside Shell Access
- Keep selected context associated with the active chat session
* Improved selected notes context handling and discouragin redundant note lookup and permission requests
* Fix selected notes UI when switching chats
* fix(notes): harden selected context lifecycle and validation
---------
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Rebuild PR C to ship the vendor/photocraft harness as a coherent unit
(vendor implementation + vendor tests) instead of a per-file ahead/behind
mix that produced broken imports and 6+ test failures.
- Flip 36 harness impl/route/test files back to vendor (the per-file
line-set policy misclassified user-rewritten files as 'behind').
- Flip core/models.py + src/tool_approval_scopes.py to vendor: vendor's
harness (authority.py, turn_contract.py, clean_agent_preview.py) depends
on vendor's older approval-scope API; upstream added
CHAT_SESSION_APPROVAL_SIGNATURE_FIELD that vendor's core/models.py lacks.
- Add src/shutdown.py (new vendor infra) required by routes/chat_routes.py.
- Keep src/agent_tools/__init__.py at upstream (editor_tools registration
lives in PR B).
Upstream-only security divergence preserved in C_upstream_delta.patch and
flagged for integration-time reconciliation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chat/agent harness work: request-authority model, turn contract, tool
policy/parsing/execution, clean_agent_preview streaming/thinking, ask_user
persistence, session probes, and scheduler.
Source: vendor/photocraft (ad1cff44) harness files, 3-way merged onto
upstream/dev (a8c147b2). Harness core files were independently developed
in both lineages; resolution policy documented in pr_work/C_conflicts.md
(vendor kept where ahead, upstream kept where vendor is behind; full delta
preserved in pr_work/C_upstream_delta.patch).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The #6503 merge added _control_plane_path() to _is_denied_tool_path(),
which grep, glob and ls call per enumerated entry. Without a snapshot
argument every call rebuilt _control_plane_snapshot() and stat'ed every
protected state file again. A 5,000-file grep went from 0.75 s to ~7 s,
and around 20k files it hits the grep timeout.
Thread an optional snapshot through _is_denied_tool_path() and
_can_traverse_tool_path(), and build it once per scan, the way
process_resources already does for its launch-boundary walk. Root
resolution and bound-resource resolution still observe fresh state.
The compact preview reduces every pre-execution tool error to a fixed
allowlist so exception text cannot reach the client. Messages our own
validators build per call are not on that list, so the model got "The
tool call could not be validated..." instead of the pdf_extract path
hint, the email uid provenance error, the artifact-Python target
message, or which schema argument was wrong. That is the default path
for the tool-profile models, Qwen3.5-9B included.
Each validator site now records its message in a per-call
validator_error before raising, and only that text is returned
verbatim. A schema failure gets a hint rebuilt from the offered schema
and the call's own arguments (missing property, expected type, allowed
values), never from the caught ValidationError. Everything else,
including all execution failures, still goes through
_public_preview_tool_error.
Request authority admits whole tool families from the user's request, so a
request to read email also admits send_email, delete_email and bulk_email,
and agent processes inherit the host network. With the gate defaulting to
off, an instruction injected through an email or a fetched page reaches those
tools with no other check; dev refuses them today.
Default the gate on, keep ODYSSEUS_TOOL_APPROVAL_GATE=0 as the opt-out, and
pin the production default with a test that imports the module in a fresh
interpreter. Four routing tests written for the opt-out posture now set it
explicitly.
Require caller-supplied model endpoints to resolve through enabled owner-visible registrations, harden session path encoding, and remove the SVG title HTML parsing sink.
The scheduled-research task reads research_max_tokens,
research_extraction_timeout_seconds, and research_extraction_concurrency
with bare int() casts and no guard. A hand-edited or agent-written
data/settings.json value like {"research_max_tokens": "invalid"} raised
ValueError and killed every scheduled research run until settings.json was
fixed by hand (#5049). src/research_handler.py already guards the same reads
defensively (_bounded_int); this mirrors that for the scheduler.
Wrap the three reads in try/except (TypeError, ValueError) falling back to
the documented defaults 8192 / 90 / 3. Valid values (numeric strings like
"4096") still parse; the change is limited to the scheduler's reads (agent_loop
sites are tracked separately in #5051).
Child of maintainer subtracker #5335 (RaresKeY) whose acceptance criteria state:
"Non-numeric agent/scheduler settings fall back safely instead of crashing or
silently degrading." Prior PR #5050 attempted the same fix but was closed
unmerged in the 2026-07-23 repo-transfer bulk close and never resubmitted —
redoing it with credit.
Fixes#5049.
Integrates the merged and frozen Wave 3 lab commit
b1666951faf8285054e1ca90f11533b0fb53fb57 with a normal merge, preserving
every Wave 4 commit unchanged.
Conflict: src/agent_runtime/resources.py. Wave 3's _control_plane_snapshot()
/ _control_plane_path(path, *, snapshot=None) split is kept. The snapshot adds
the effect-store directories to its prefix set after the recursive job-dir
inventory and no longer references path (the auto-merged prefix check would
have raised NameError there). _control_plane_path calls _aliases_effect_store
after its os.stat, only for multiply linked files, so single-link files never
list the store.
Semantic reconciliation (no textual conflict): bg_monitor keeps launch
settlement right after the first successful validate_job and before the
authority check, with Wave 3's post-drain revalidation intact. The
deleted-session branch, terminal before linkage validation, now settles a
validated launch too: that job is later pruned and its publication retired,
which would otherwise leave its effect RUNNING. Regression tests cover the
snapshot form of the effect-store check and both deleted-session linkage
outcomes.
A hardlink into the effect store was protected only by the log's own nlink
refusal, which an agent could undo by removing the alias after writing
through it. Adding the store to the recursive control-plane inventory would
make every path check cost grow with accumulated runs. _aliases_effect_store
instead uses the store's invariants: logs and launch indexes refuse
st_nlink != 1 and the store is flat, so only a multiply linked regular file
on the store's device is compared by inode against one non-recursive listing.
Single-link files cost nothing and the store is never rglob-inventoried. The
helper takes a stat result so it plugs into Wave 3's scan-local snapshot after
the rebase.
Effect obligations were consulted only for declared artifacts, and reported
external success could be presented as done. Now, regardless of declared
artifacts:
- the latest effect on any changed file contradicted by a fresh readback
fails the run (a superseded earlier effect is history, not a contradiction);
- a passing verifier followed by an effect that may have changed state
without settled evidence is stale (BLOCKED);
- executed external effects that are not VERIFIED cap the decision at
UNVERIFIED, and the answer always carries server-authored facts for them
("reported success; any external change it made was not independently
verified", "reported failure", "unknown outcome").
The disclosure is structural and does not depend on recognizing the model's
wording. When it is the only change, the model's answer events are released
unchanged and the disclosure follows as one delta (and in round_texts).
Prose filtering is also tightened (remote verbs are mutation claims, an
unnamed "I updated it" cannot borrow the single required artifact, bare
"Done." is a terminal claim beside unverified external effects). A passing
verifier still supports test claims; it never speaks for the external effect.
Replaces the uncommitted attempt that blocked every run with any RUNNING
effect: a background launch with no declared obligations completes
UNVERIFIED.
Result-dictionary keys could set lifecycle state for any producer: a dynamic
or registry tool returning bg_job_id/detached became RUNNING, teardown became
verified cleanup, and timed_out/failure_kind/mutation_attempted/containment
were copied from untrusted results. Facts are now scoped to the producer the
dispatcher actually bound. An unbound tool contributes its exit status alone.
RUNNING requires a bound process producer (and an exact launch reservation
for bg_job_id), cleanup is attested only by a bound process producer, and job
observations and launch settlement only by a bound manage_bg_jobs operation
on exactly one Wave 3-validated job. External/remote-acknowledged facts come
from the captured ExternalResource, not from the result.
Sequence positions were allocated from each EffectLog object's in-memory
counter, so two objects, threads or processes could reuse a position or
settle one effect twice; replay then failed closed for the whole log. Every
append now takes an exclusive flock, merges the durable records other writers
appended (truncating a torn tail a crashed writer left), allocates from that
merged tail, rejects records the merged history makes invalid (a second
settlement, recovery of a claim another writer settled or marked running),
then appends, fsyncs and releases. history() merges others' records under a
shared lock. An incremental consistency index keeps appends O(1).
The first append of each log object fsyncs the log's directory, and every
directory created for it is fsynced in its parent, all under the lock before
the claim returns. A failed write or directory fsync truncates the record
back, so dispatch is refused and nothing unacknowledged is later merged. The
launch index writes and fsyncs a temp file, replaces it, then fsyncs the
directory. flock and directory fsync are POSIX-only and not claimed
elsewhere.