fix(capture): make the write-gate bypass rule an allowlist (#365 follow-up) - #380
Conversation
…ow-up) Follow-up to #375, per review. The origin control was a denylist: _ORIGINS_REFUSED_CONTENT_BYPASS = frozenset({ORIGIN_NETWORK}) so anything not literally named webfetch or websearch could claim a content-derived bypass. Classification already failed safe — an unrecognised tool lands in UNKNOWN rather than being promoted to LOCAL_ACTION — but the security decision built on it failed open. The trigger is not hypothetical: _NETWORK_TOOLS hardcodes two names against a host whose tool surface changes. A third off-machine tool, or a rename, classifies UNKNOWN and silently regains the bypass — the control stopping with no failing test and no signal, which is the shape of the issue #375 closes. Inverted to an allowlist of {DELIBERATE, LOCAL_ACTION}. An unclassified origin is refused; the cost of a missing classification is a rejected write instead of a trusted one. Two things fell out of it. ORIGIN_DELIBERATE was defined, documented in the vocabulary and produced by NOTHING — dead since it was written. Under an allowlist a direct `remember` would resolve UNKNOWN and lose the content bypass it has always had, so the value now has a producer: a `remember` carrying no producing tool is the user asking directly. The condition is the ABSENCE of a tool name, not an UNKNOWN classification — those differ exactly where it counts, since a named-but- unrecognised tool also classifies UNKNOWN and promoting that would reinstate the fail-open just removed. tests_py/infrastructure/ test_capture_origin_persistence.py caught that in the first draft. The issue #147 ordering (deliberate never novelty-rejected, but a specific content reason still wins over the generic one) broke: a deliberate write's reason degraded from bypass_error to bypass_write_class_deliberate. Such a write bypasses either way, so refusing it the specific label changed no outcome and only destroyed a diagnostic. The origin rule now governs whether content may BUY a bypass, not how an already-granted one is labelled. Seven tests in test_write_gate.py were silently relying on the permissive default and now state which channel produced their content — which is what they were asserting all along: error-shaped content FROM A LOCAL TOOL bypasses, not error-shaped content from anywhere. Verified: pytest -k "gate or capture or remember or origin or write_class" -> 453 passed, 13 subtests passed ruff check / format --check -> clean Refs #365 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#375's entry described the denylist it shipped: "network-origin content is refused the two content-derived bypasses" and "unrecognised tools classify as unknown rather than trusted, so a newly added tool is visibly unclassified". Both are now wrong in the same direction — unknown is refused, not merely visible — so the release notes would have contradicted the code. Restated as what it is: an allowlist of {deliberate, local_action}, with the reason a denylist was abandoned (it fails open the moment the host adds or renames a network tool) and the distinction between a tool that was NAMED but is unrecognised and no tool being named at all. docs/mcp-tools.md's write-path description carried the same denylist framing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Ledger row H4 updated: the CHANGELOG is no longer deferred. #375's entry described the denylist it shipped — "network-origin content is refused the two content-derived bypasses" and "unrecognised tools classify as Amended in place rather than appended to, so there is one account of the behaviour instead of two that disagree. It now states the allowlist, why the denylist was abandoned (it fails open the moment the host adds or renames a network tool), and the distinction between a tool that was named but unrecognised and no tool named at all.
|
Follow-up to #375, implementing the review finding. Refs #365.
What was wrong
The origin control was a denylist:
Classification already failed safe — an unrecognised tool lands in
UNKNOWNrather than being promoted toLOCAL_ACTION. But the security decision built on top of it failed open: anything not literallywebfetchorwebsearchcould claim a content-derived bypass.The trigger is not hypothetical.
_NETWORK_TOOLShardcodes two names against a host whose tool surface changes. Add a third off-machine tool, or rename one, and its content classifiesUNKNOWNand silently regains the bypass — the control stopping with no failing test and no signal. That is the same shape as the issue #375 closes.Now an allowlist of
{DELIBERATE, LOCAL_ACTION}. An unclassified origin is refused, so the cost of a missing classification is a rejected write instead of a trusted one.Two things the change forced out
ORIGIN_DELIBERATEwas dead. Defined, documented in the vocabulary, produced by nothing since it was written. Under an allowlist a directrememberwould resolveUNKNOWNand lose the content bypass it has always had, so the value now has a producer: aremembercarrying no producing tool is the user asking directly.The condition is the absence of a tool name, not an
UNKNOWNclassification. Those differ exactly where it counts — a named-but-unrecognised tool also classifiesUNKNOWN, and promoting that toDELIBERATEwould reinstate the fail-open this PR removes. My first draft got this wrong andtest_capture_origin_persistence.py::test_unrecognised_tool_persists_unknown_not_a_guesscaught it.The issue #147 ordering broke. A deliberate write's reason degraded from
bypass_errorto the genericbypass_write_class_deliberate, masking the diagnostic that test exists to protect. Since such a write bypasses either way, refusing it the specific label changed no outcome and only destroyed information. The origin rule now governs whether content may buy a bypass, not how an already-granted one is labelled.The honest cost
The original design note said
UNKNOWNwas chosen so "adding this parameter changes no existing caller's behaviour". Under an allowlist that is no longer true, and seven tests intest_write_gate.pywere silently relying on it. They now state which channel produced their content — which is what they were asserting all along: error-shaped content from a local tool bypasses, not error-shaped content from anywhere.That is the trade in one line: the permissive default was doing real work, and now every caller has to mean it.
Completion Ledger
pytest -k "gate or capture or remember or origin or write_class"→ 453 passed, 13 subtestsUNKNOWNrefused, unrecognised origin refused, network refused; each with its own testDELIBERATEkeys on tool absence and is additionally gated on a write class the auto-capture hook pins out-of-bandmay_bypass_write_gate_on_contentkeeps its signature; the semantics invert, which is the intended behaviour changewrite_gate.determine_bypassandhandlers/remember; both updated and covered. Seven test call sites made explicitcapture_origincolumn now recordsdeliberatefor direct writes where it recordedunknown; more accurate, no migration neededdetermine_bypassreturns(False, None)and the gate reports a rejectionUNKNOWNrefusal, unrecognised-origin refusal, absent-tool promotion, unrecognised-tool non-promotion, deliberate-keeps-specific-reasontest_unrecognised_tool_persists_unknown_not_a_guessis the regression test for the flaw in my own first draftUNKNOWNmay not bypass; a caller naming no origin gets(False, None)ruff check .clean ·ruff format --check .clean (1283 files)ORIGIN_DELIBERATE) that predates this PR