Skip to content

fix(disk-hygiene): deny when the guard's decision cannot be written to stdout - #1588

Merged
kyle-sexton merged 4 commits into
mainfrom
fix/1449-followup-stdout-failopen
Jul 26, 2026
Merged

kyle-sexton merged 4 commits into
mainfrom
fix/1449-followup-stdout-failopen

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #1449, which closed six fail-open paths in destructive_guard.py but not this one — none of its in-process tests could reach the failure.

_decide writes its decision with print, which only buffers. A closed or lost stdout pipe therefore raises nowhere inside main — the failure surfaces at interpreter shutdown, and CPython reports a failed shutdown flush by replacing the exit status with 120.

Measured on merged efb6c271 (deny decision, stdout wired to a pipe whose reader is closed):

deny + broken stdout -> exit 120   "Exception ignored while flushing sys.stdout"

120 is "any other exit code" under the PreToolUse contract — non-blocking, so the destructive command runs even though the guard had decided to deny it. Same shape as #1423, reached from the one stream #1449's stderr work did not cover.

The module tail now flushes stdout itself, so an undeliverable decision is caught while there is still a decision to make: it denies at exit 2 with a diagnostic, because a decision the host never received is not a decision. It then flushes stderr best-effort and os._exits the resolved code, so a shutdown flush can no longer rewrite it. #1449's null-device fallback is reused rather than duplicated — extracted from _write_diagnostic as _discard_stream.

A follow-up commit then removed that same _discard_stream(sys.stdout) call from main's deny handler once re-verification showed the module tail's explicit flush — not the discard call — was what actually closed the exit-120 path; the discard call only changed which of two true diagnostics was emitted.

Test plan

  • test_undeliverable_stdout_decision_denies_at_exit_2_in_a_real_process spawns the real script against a genuinely closed stdout pipe and asserts exit 2 with the diagnostic; it exits 120 against the pre-fix tail.
  • Re-verified the redundant-discard-removal commit: deny with stdout wired to a pipe whose reader is closed still exits 2 with "the decision could not be written to stdout"; confirmed neither this module nor killswitch_config registers an atexit handler or __del__, which the tail's os._exit would otherwise skip.
  • 212 tests pass.

Related

Closes #1423 (the remaining stream not covered by #1449).

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

kyle-sexton and others added 2 commits July 26, 2026 04:47
…o stdout

Follow-up to #1449, which closed six fail-open paths in `destructive_guard.py`
but not this one, because none of its in-process tests can reach the failure.

`_decide` writes its decision with `print`, which only buffers. A closed or lost
stdout pipe therefore raises nowhere inside `main` — the failure surfaces at
interpreter shutdown, and CPython reports a failed shutdown flush by replacing
the exit status with 120. Measured on merged `efb6c271`, deny decision, stdout
wired to a pipe whose reader is closed:

    deny + broken stdout -> exit 120   "Exception ignored while flushing sys.stdout"

120 is "any other exit code" under the PreToolUse contract
(https://code.claude.com/docs/en/hooks) — non-blocking, so the destructive
command runs even though the guard had decided to deny it. Same shape as #1423,
reached from the one stream #1449's stderr work did not cover.

The module tail now flushes stdout itself, so an undeliverable decision is
caught while there is still a decision to make: it denies at exit 2 with a
diagnostic, because a decision the host never received is not a decision. It
then flushes stderr best-effort and `os._exit`s the resolved code, so a
shutdown flush can no longer rewrite it. #1449's null-device fallback is reused
rather than duplicated — extracted from `_write_diagnostic` as
`_discard_stream`.

`test_undeliverable_stdout_decision_denies_at_exit_2_in_a_real_process` spawns
the real script against a genuinely closed stdout pipe and asserts exit 2 with
the diagnostic; it exits 120 against the pre-fix tail. 212 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… handler

`_discard_stream(sys.stdout)` in `main`'s `except` claimed to be what stopped a
broken stdout rewriting the deny into a 120. It is not — that configuration was
measured at 120 with the call present, and only the module tail's explicit
flush closed it. With the tail in place the call changes nothing but which of
two true diagnostics is emitted, so the comment justifying it was wrong and the
call is one mechanism too many. The tail owns the stdout deadline alone.

Re-verified: deny with stdout wired to a pipe whose reader is closed still exits
2 with "the decision could not be written to stdout". Also confirmed neither
this module nor `killswitch_config` registers an `atexit` handler or `__del__`,
which the tail's `os._exit` would otherwise skip. 212 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 26c0b7f886

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py
kyle-sexton and others added 2 commits July 26, 2026 15:24
…d mode

The undeliverable-stdout test spawns the real guard with a copy of the
suite's own environment. When the suite runs under PYTHONUNBUFFERED, the
child inherits it, so the decision `print` raises at the call instead of
buffering. That still denies at exit 2, but by `main`'s generic
internal-error path, never reaching the interpreter-shutdown flush handler
the test exists to cover -- so its diagnostic assertion failed.

The raised exception is platform-dependent (BrokenPipeError on POSIX,
OSError errno 22 on Windows), so widening the assertion to accept both
diagnostics would trade a real regression signal for a platform guess.
Dropping the variable from the child environment keeps the test pinned to
the one path it means to exercise, however the runner was invoked.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@kyle-sexton

kyle-sexton commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 Released by autonomous babysit lane (fable-autopilot). PR merged as 0a0e2fa (squash). The one open review thread was verified and resolved; both rate-limited AI review jobs were re-run to genuine completion first, since security-review / security-review is a required context and had reported success without the review actually running.

@claude

claude Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 50s —— View job


PR Review

  • Gather context (diff, changed files)
  • Understand the request
  • Review the changed code
  • Post findings

Scope: One commit, one file — 189658a adds env.pop("PYTHONUNBUFFERED", None) to the real-process stdout-failure test introduced in 2de5f36.

The fix is correct and complete

The Codex P2 comment identifies the exact problem this commit resolves. When the test suite runs under PYTHONUNBUFFERED=1 (e.g., python3 -m unittest in a CI harness that sets it), the child inherits the variable and Python's text streams run unbuffered. In that configuration the print in _decide raises immediately — BrokenPipeError on POSIX, OSError errno 22 on Windows — inside main, which the outer except BaseException catches and handles via the generic internal-error diagnostic. The subprocess exits 2, but with the wrong message: internal error ... BrokenPipeError, not could not be written to stdout.

That path is correct behaviour, but it's not the path this test was written to exercise. The test exists to verify the module-tail flush handler that was the actual fix for the exit-120 regression. With the inherited variable, that handler is never reached, and the diagnostic assertion at line 3999 fails.

Dropping PYTHONUNBUFFERED from the child's environment is the right fix — not widening the assertion, because widening it would collapse the regression signal onto a platform guess. The child buffers as normal, the buffer flush fails at interpreter shutdown, the module tail catches it, and the diagnostic is the one the assertion expects.

The comment at lines 3970–3974 explains this correctly and concisely.

Sibling tests are not affected

The two other real-process tests that copy os.environ (lines 4027, 4140) both use stdout=subprocess.PIPE — a live, connected pipe — so PYTHONUNBUFFERED cannot make their stdout writes fail. The scoping to this test only is correct.

Minor observation (no action required)

Line 3998: assertNotEqual(120, proc.returncode) is logically redundant once assertEqual(2, proc.returncode) on line 3997 passes. It reads as a regression label rather than a guard — fine as documentation, and the PR description calls this out explicitly.

Verdict

Clean, minimal, correctly targeted test fix. No correctness, security, or architecture concerns. Addresses the Codex P2 finding fully.

@claude

claude Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 2m 40s —— View job


Security Review — PR #1588

  • Gather PR diff and context
  • Read changed files
  • Perform security analysis
  • Post findings

Reviewed commits: 2de5f36, 26c0b7f, 189658a
Scope: destructive_guard.py (core fix + redundant-call removal), test_hygiene.py (test environment fix)


No exploitable security issues found

The changes close a genuine, previously-verified fail-open path and do so correctly. Below is a trust-boundary walk for each new piece.


Module-tail flush + os._exit (core fix)

if __name__ == "__main__":
    _exit_code = main()
    try:
        sys.stdout.flush()
    except BaseException:
        _write_diagnostic("...denying by default.")
        _discard_stream(sys.stdout)
        _exit_code = 2
    with contextlib.suppress(BaseException):
        sys.stderr.flush()
    os._exit(_exit_code)

Property verified: If stdout delivery fails at flush time, the exit code is forced to 2 (deny) before os._exit is called. os._exit bypasses CPython's own shutdown flush sequence, so it cannot be rewritten to 120 afterward. The only two reachable exit codes after this tail are 0 (delivered allow) and 2 (deny — either deliberate or because delivery failed). This is the correct invariant for a security gate.

Watchdog race: _watchdog_fire calls os._exit(2) from a background thread on deadline. If it races with the module tail, whichever wins first exits with 2 — the deny is still delivered. No fail-open race.

atexit skip: os._exit skips registered atexit handlers. The PR description and its commit message document that neither destructive_guard nor killswitch_config registers any — this was verified by inspection. This is the correct tradeoff given that the alternative (CPython's shutdown replacing the exit code) is the vulnerability.


_discard_stream helper

def _discard_stream(stream: object) -> None:
    with contextlib.suppress(BaseException):
        null_fd = os.open(os.devnull, os.O_WRONLY)
        try:
            os.dup2(null_fd, stream.fileno())
        finally:
            os.close(null_fd)

Callers: sys.stdout and sys.stderr only — both trusted interpreter-owned objects. No external input reaches this function. os.devnull is a constant. No injection surface.

fd leak: The try/finally ensures null_fd is closed even when os.dup2 or stream.fileno() raises. contextlib.suppress(BaseException) ensures the function itself never raises to its callers. No fd leak path.

Double-null open: If both os.open(os.devnull, ...) calls race (watchdog + module tail calling _discard_stream on the same fd simultaneously), the result is two fd-2 replacements both pointing at /dev/null, which is idempotent and harmless.


Redundant-discard removal (26c0b7f)

Removes _discard_stream(sys.stdout) from main's except handler. The commit documents why this call was ineffective (the broken-pipe exception from _decide's print reaches main's handler only when unbuffered; buffered, it escapes the try-block entirely into the module tail). Removing it reduces the mechanism count and leaves a single authoritative owner for the stdout deadline. No security regression — the tail's flush is what closes the vulnerability in both buffered and unbuffered modes.


Test environment fix (189658a)

env = dict(os.environ)
env.pop("PYTHONUNBUFFERED", None)

This modifies a shallow copy of os.environ, not os.environ itself. The removed key prevents the child guard process from inheriting unbuffered mode, keeping the test pinned to the buffered-shutdown path it is designed to exercise. No capability expansion, no injection risk.


Summary

All three trust boundaries in the new code — the module tail's flush-and-exit, the _discard_stream fd redirect, and the test's env sanitization — are implemented correctly. The fix correctly closes the exit-120 fail-open path reported in #1423, and the test correctly isolates the buffered code path from runner-environment variation. No security issues to report.

@kyle-sexton
kyle-sexton merged commit 0a0e2fa into main Jul 26, 2026
34 checks passed
@kyle-sexton
kyle-sexton deleted the fix/1449-followup-stdout-failopen branch July 26, 2026 21:24
kyle-sexton added a commit that referenced this pull request Jul 29, 2026
)

🤖 Agent-authored (autonomous babysit lane).

Closes #1611. Closes #1640.

## Summary

The engine gate decided marker relevance with a bare substring test over
the command string, so `test_hygiene.py` — this plugin's own test suite
— read as an engine invocation. In any consumer session with the plugin
enabled, the natural commands for working on the plugin were denied, on
the Bash tool and on PowerShell alike:

```text
Disk-hygiene fails closed: Bash is restricted to exact bundled scan, preview,
handoff-verify, and apply invocations...
```

That is the response to `python3 -m unittest -v …/test_hygiene.py`, and
to `ruff check …/test_hygiene.py`. Both were hit for real while
preparing this PR; the lint had to be rerun over the directory instead,
and the commit for this very fix was refused until its message stopped
naming the file. The bug blocks its own repair.

### The interesting part: only half the function was wrong

The literal-parse path was **already correct** — it basename-matched
(`Path(word).name == _ENGINE_MARKER`) and deferred properly. Only the
operator-carrying path used a substring. That is why the failure looked
arbitrary: the *same* command gated or deferred depending on whether it
happened to contain a `&&`.

```text
python3 -m unittest -v …/test_hygiene.py                 → defer   (parseable, basename-matched)
cd /repo && python3 -m unittest -v …/test_hygiene.py     → DENY    (unparsable, substring-matched)
```

Both now go through one `_carries_marker` helper — including the literal
branch, which had kept its own `Path().name` test. That was not
cosmetic: `Path().name` is platform-flavoured, so `PATH=/x:hygiene.py
python3 …` gated on Windows and deferred on Linux.

### Precision, not relaxation

A basename test is only as good as the tokens it reads. A naive per-word
test would open a seam that substring matching had closed:
`foo;hygiene.py` is a single whitespace token whose basename is the
whole glued string.

My first cut paid for that by splitting on the module's
shell-metacharacter set — **and that was wrong**, as codex's P1 review
caught. An assignment glues with `=`, which is not a metacharacter, so
`engine=hygiene.py && python3 "$engine" apply` presented no token whose
basename was the marker and **bypassed the guard entirely**. Probing
around the report found three more shapes with the same cause.

Enumerating shell syntax is the wrong shape for this question — every
glue character missed is a silently un-gated invocation. The delimiter
is now the complement of what a path may contain,
`[^A-Za-z0-9._\-/\\:]+`. That inversion is **total rather than
enumerative**: `hygiene.py` is spelled entirely from the kept
characters, so splitting on everything else can only ever *expose* the
engine filename as its own token, never destroy an occurrence.

### The structural fix: detection and resolution are different questions

That total splitting is right for **detection** and wrong for
**resolution**, and one token list was serving both. The "provably a
DIFFERENT file" escape requires an ABSOLUTE path, but it read the fine
fragments — so any character outside the keep class split a consumer's
absolute path and left the fragment carrying the filename relative,
unprovable, and denied. `python3 /tmp/consumer+tools/hygiene.py --help
&& echo done` gated a file with nothing to do with this plugin. That was
#1640, and `~` is what makes it ordinary rather than exotic: Windows 8.3
short-name segments (`C:\Users\KYLESE~1\…`) put unpunctuated paths under
ordinary temp directories into the same population.

Resolution now reads the **whole shell word** containing the token, with
quoted spans kept intact so a path with spaces resolves too. Detection
keeps the fine tokens exactly as they were, and a test pins the two
partitions to the same token list so that claim is checked rather than
argued.

The widening cannot travel. A token is paired with its enclosing word by
**span**, never by substring containment — containment would let
`python3 /abs/consumer/hygiene.py --help && python3 hygiene.py apply`
borrow the first word's absolute path to "prove" its bare second
invocation different, and defer while the real engine ran. That shape is
now a regression test.

### Identity outranks spelling

`cd <plugin-scripts> && python hygiene.py. apply` opened and ran the
kill-switched engine while the guard deferred: Win32 discards trailing
dots and spaces from a filename and resolves `::$DATA` to the main data
stream, so the filesystem hands back the engine under a name whose
basename is not the marker. 8.3 short names are a third spelling of the
same kind.

The fix asks the filesystem instead of listing spellings. A relative
word is identity-checked against the **engine's own directory** —
precisely the directory such a command must `cd` into for the alias to
run. That closes trailing dots, trailing spaces, NTFS stream suffixes,
and short names in one move, where enumerating them closes one per
review round. The name predicate is unchanged and stays
platform-independent; identity carries this.

### The filing caveat is discharged

#1611 was filed with an explicit warning from me that the fix was
unverified against the gate's suite and that the substring **might be
load-bearing for a copy-evasion case I had not identified**. Resolved
empirically before touching the code: it is not.

Evasion coverage never routed through the marker.
`test_engine_gate_catches_linked_aliases_of_the_bundled_engine` and
`test_engine_gate_catches_alias_beside_shell_operator` create a hard
link to the engine under an unrelated name (`clean-engine`) and assert
it gates — by `os.path.samefile` identity, which does not consult
`_ENGINE_MARKER` at all. A byte copy remains the accepted residual the
function's docstring already names as unclosable. Narrowing the marker
cannot regress a mechanism that does not use it.

## Test plan

**Differential over 89 command shapes**, captured on the guard at the
merge commit, then re-run after the change, requiring that only the
intended rows move. The matrix started at 26 shapes and grew with each
review round, because each round showed my existing cases shared an
assumption that turned out to be wrong.

| Group | Cases | Before → After |
| --- | --- | --- |
| Engine invocations, wrappers, `bash -c` compound | 7 | GATE → GATE |
| Metacharacter, assignment, and gluing shapes | 9 | GATE → GATE |
| Line continuations, post-`cd` relative paths | 5 | GATE → GATE |
| Linked aliases (suite-named and unrelated) | 6 | GATE → GATE |
| Proof-borrowing shapes (new) | 7 | 6 GATE → GATE, **1 defer → GATE** |
| Windows filename aliases (new) | 6 | **4 defer → GATE**, 2 unchanged |
| `PATH=/x:hygiene.py` platform divergence | 1 | **defer → GATE** |
| Consumer-owned scripts, all parent spellings | 19 | **14 GATE →
defer**, 5 unchanged |
| Mentions, unrelated commands, near-miss names | 10 | defer → defer |
| The #1611 bug (`test_hygiene.py`, 5 shapes) | 5 | defer → defer |
| `_carries_marker` predicate assertions | 10 | unchanged |

**Six shapes move toward gating** — every one a fail-open that is now
closed. **Fourteen move toward deferral** — every one a consumer's own
file, denied only because of how its parent directory was spelled.

**Suites** — full plugin, all green:

| Suite | Result |
| --- | --- |
| `test_hygiene.py` (unittest) | **238 passed**, 4 skipped (+4 new
tests) |
| `test_guard_launch_monitor.py` | included above, passing |
| `ruff check` over the scripts dir | All checks passed |

**Four regression tests added** in this round, on top of the six from
earlier rounds:

- `test_engine_gate_defers_consumer_paths_outside_the_path_legal_class`
— the #1640 population, across `+`, `@`, `=`, `~`, and a space in the
parent name, in both operator shapes.
- `test_engine_gate_catches_windows_equivalent_filename_aliases` —
trailing dot, multiple dots, and `::$DATA`. Written as probe-then-assert
rather than gated on `sys.platform`: these are aliases only where the
filesystem says so, and round 2 of this chain was a host-dependent
verdict that passed locally and failed open in CI.
- `test_engine_gate_marker_token_cannot_borrow_proof_from_another_word`
— the fail-open that containment-based pairing would have introduced.
- `test_marker_token_words_partition_matches_the_split_partition` —
mechanical proof that widening resolution did not change detection.

**One pre-existing test was modified, and it is worth saying why rather
than letting it read as gaming CI.**
`test_engine_gate_defers_consumer_windows_path_on_powershell` forced
backslashes onto its fixture path unconditionally, which on POSIX
fabricates a path that cannot exist. It passed on Linux only because the
basename predicate was `Path()`-flavoured and did not see the marker
inside that backslash word — a Linux fail-open wearing a green test,
which is round 2 of this chain exactly. Once the literal branch shares
the platform-independent `_carries_marker`, that word is an unresolvable
marker-carrying path after an interpreter, and failing closed on it is
the documented rule. The test now asserts on the host's own spelling and
adds the backslash spelling only where the filesystem says it names the
same file. Windows coverage is unchanged; the differential shows no
movement on that shape.

**Repo gates**, all pass: `markdownlint-cli2`, and the changed-plugin
gates via CI.

Version bumps to `0.10.2` with `### Fixed` entries, per repo convention.
(`0.10.1` landed on `main` from #1637 while this was in review.)

One note for the reviewer: `ruff format --check` reports 5 files in this
directory as unformatted, but it reports the identical 5 at
`origin/main` — including files this PR never touches. Pre-existing and
not enforced here; deliberately left alone rather than bundling a large
unrelated reformat into a guard fix.

## Review chain — read this before the diff

Codex reviewed this six times and found **five real defects, four of
them fail-opens I introduced**. All six threads are resolved with a
reproduction table each. Recorded here because the diff looks small and
the history is the honest signal about it:

| Round | Finding | Verdict |
| --- | --- | --- |
| 1 | `engine=hygiene.py && python3 "$engine" apply` bypassed the guard
— `=` is not a shell metacharacter | Fixed; probing found 3 more shapes
of the same class |
| 2 | Bash line continuation left `hygiene.py\` welded together | Fixed
— and exposed that `Path().name` is platform-flavoured, so the bug was
**invisible on my Windows host and live on Linux** |
| 3 | Relative marker path after an in-command `cd` resolved against the
guard's cwd | Fixed; also closed 2 pre-existing fail-opens, and caught
that my own round-2 fix had been severing Windows drive letters |
| 4 | Consumer's absolute path containing `+`, `@`, or `~` over-gates |
Fixed in this round — resolution now reads the whole shell word (closes
#1640) |
| 5 | A link to the engine *named* `test_hygiene.py` beside an operator
deferred | Fixed — the deferral this PR adds had become a bypass under
the one name it makes non-marker |
| 6 | Trailing-dot Windows alias (`hygiene.py.`) bypassed the gate |
Fixed in this round — identity against the engine's own directory, which
subsumes the whole alias class |

### On the stopping rule

Rounds 4 and 6 were parked rather than fixed, under a stopping rule I
pre-committed to: a fail-open found after round 5 means hand the PR to a
human, because at that point the sustained defect rate is itself the
finding. **That rule was overridden by explicit operator direction** to
drain the queue and fix findings in place. Recording it plainly rather
than resuming silently past a stated pre-commitment.

The diagnosis behind the rule stands and shaped this round's fix. My
verification had been **enumerative** — it tested the shapes I thought
of, and codex kept finding shapes I did not. Both fixes here are
deliberately non-enumerative: resolution reads whole words rather than a
wider character class, and alias handling asks the filesystem rather
than listing spellings. Each closes a *class* instead of an instance,
which is the only kind of fix that answers the round-6 objection.

### The property to check, if you check one thing

Across the differential against the pre-change guard, every shape whose
verdict moves toward *deferral* is a consumer's own file, identified by
an absolute path that resolves to something other than the bundled
engine. Every shape that moves toward *gating* is a fail-open. No shape
moves toward deferral on the strength of a relative path, a name alone,
or a word other than its own.

## Related

- Closes #1611 — the engine gate denying unrelated files whose name ends
in `hygiene.py`
- Closes #1640 — over-gating on consumer paths outside the path-legal
keep class
- Found while babysitting #1588, where the same denial blocked running
the suite for that fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

disk-hygiene: destructive guard exits 1 with empty stderr after 17s — a live fail-open distinct from #1242

1 participant