Skip to content

fix(apps): refuse a corrupt document in the four update readers instead of replacing it (#7805) - #8084

Merged
bolichen97 merged 1 commit into
mainfrom
fix/refuse-corrupt-rmw-7805
Sep 4, 2026
Merged

fix(apps): refuse a corrupt document in the four update readers instead of replacing it (#7805)#8084
bolichen97 merged 1 commit into
mainfrom
fix/refuse-corrupt-rmw-7805

Conversation

@bolichen97

@bolichen97 bolichen97 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator
Backend-only: four JSON store readers and their route handlers; no UI surface changes, so no screenshots.

Summary

Closes #7805.

The four merged *_for_update read-modify-write readers — shares._load_for_update, library._read_ledger_for_update, KeystoneFileBackend._read_for_update (the provider credential store), and policy_store._read_for_update (the keystone policy file) — caught json.JSONDecodeError alongside FileNotFoundError and returned an empty container, so a corrupt or truncated file was silently read as empty and then rewritten whole, destroying bytes a person could still have recovered by hand. This mirrors the #7794 decision for the incident index and app config: only a MISSING file reads as empty; corruption refuses.

Per reader:

  • All four propagate parse failures, wrap non-UTF-8 byte streams (UnicodeDecodeError is a ValueError but NOT a JSONDecodeError, so unwrapped it slips past every corruption clause), and refuse valid JSON whose root has the wrong type (which parses without raising, so normalizing destroys a document nobody could read).
  • secrets.py additionally refuses any entry the coercion would lose (deserialize → re-serialize → refuse if anything on disk did not survive), and its refusals carry NO document content: fixed messages, empty doc, and a fully severed exception chain (__cause__/__context__ both None), because here the raw file text IS the credential store. The parser's real line/column are folded into the message text.
  • shares.py also checks per ROW that expiresAt is readable, because both mutations pipe the reader's return through _prune, whose damage path silently drops rows it cannot read a stamp from — the same coercion loss, one call later. The deliberate expiry drop (parseable stamp in the past) remains retention.
  • The ops-mission-control pair raises the named CorruptDocumentError; the aws_control pair raises plain json.JSONDecodeError (the named type lives in another app; apps do not import each other).

Every caller of the four mutation paths was audited for the JSONDecodeError-under-ValueError trap the issue names:

  • aws_control push and remove routes gained a corruption arm AHEAD of their tolerant except ValueError arms (without it, corruption reads as not_pushable / invalid_slug — a 400 blaming the client for a store that needs repair).
  • The share mint route withholds a minted-but-unrecorded presigned URL (returning it would create a live unrevokable bearer grant with no local record); mint/forget answer a coded 500 share_ledger_corrupt via a shared helper.
  • The reconcile caller degrades to reconciled: false with a repair-worded remoteError and keeps rendering (rows come from the lenient display read).
  • ops-mission-control secret save/revocation answer the shared _store_read_refusal mapper's coded 500 secret_store_corrupt; the policy-store writes were already wired through _settings_write_or_refuse's corruption arm.

Display/lookup reads stay lenient — that asymmetry is the point — and LOG when they degrade for any reason other than an absent file, mirroring #7794's display reads. Per-call logging with no dedup matches the merged precedent (store._read_index_unlocked). All four also now tolerate UnicodeDecodeError (previously it escaped them) — safe because every value read leniently now has a restrictive default. The one exception, primary_instance (defaults True, so a degraded read GRANTS ledger-prune authority — the corrupt file becoming the key that unlocks destroying shared knowledge), no longer reads through the lenient path at all: rotation.is_primary now uses the new strict policy_store.read_authority, which reuses the update reader's corruption doors and answers False when the file cannot be read. Two GPT lane rounds drove this: round 1 flagged the widened door, round 2 correctly rejected scoping the pre-existing doors out as out-of-scope.

Deliberately untouched, same as the issue scopes it: _update_ledger's per-ACCOUNT scalar reset (replaces one account's unusable entry while carrying every other row forward; the sidecar-preserve alternative is tracked in #7789). Two further lenient-read-feeding-rewrite siblings named by the First Principles lane are tracked: backup.py as item 1 of #7789, mochi/pinned_files_service.py as #8088. The stale "siblings need a follow-up" rationale in store.py and test_store_and_gate.py is updated to record that #7805 landed it.

Review

Two pre-push blind review lanes (GPT 5.6, Opus). Both blocking findings fixed at root:

  • GPT: the strict-coercion refusal interpolated the untrusted provider key (counterexample {"<token>": "scalar"}) and raise ... from exc kept the full credential document reachable via __cause__.doc. Fixed: position-only message; chain severed by raising outside the handler; adversarial token-shaped-key test asserts the whole chain.
  • Opus: a damaged-but-parseable share ledger was still rewritten (_prune drops rows on KeyError/ValueError/TypeError), while the reader's docstring claimed the case could not happen. Fixed: strict per-row check; docstring corrected; regression test reproduces the reviewer's measured 3-row loss.

Accepted advisories: stale #7794 docstrings updated; nosemgrep annotation on the new fixed-literal warning (sibling precedent in-file); CorruptDocumentError pinned in policy tests; display-read UnicodeDecodeError tolerance now tested per module. Not changed: per-call degradation logging (matches merged #7794 precedent); the route test's assertNotIn(token, …) belt assertion stays alongside the discriminating doc-bytes assertion.

Tests

  • 1279 passed / 44 skipped across test/test_aws_control_app.py, test/test_aws_control_routes.py, test/test_aws_control_library.py, and the full ops_mission_control suite.
  • New regression tests per reader: corrupt file refuses AND is left byte-for-byte intact; absent file is still a first write; non-UTF-8 takes the corruption path; valid-JSON-wrong-root refuses; secrets coercion-lossy entry refuses; shares damaged-row shapes (non-object row, missing stamp, mangled stamp) refuse while expired rows stay retention; no document bytes on any refusal (message, doc, or exception chain, token-shaped-key adversarial case).
  • Route-level: corrupt secret store answers coded 500 on save and revocation; push answers library_ledger_corrupt not not_pushable; remove answers it not invalid_slug; share mint withholds the URL; library list survives a corrupt ledger and reports repair.
  • Mutation-verified both directions: 10 distinct mutations (lenient-read restore ×4, root-check drop, strict-coercion drop, doc-leak restore ×2, per-row-check drop, route-arm removal ×3) each caught by a different named test.
  • Local gates: black-baseline (2 graduated entries pruned), isort, flake8 7.1.0, mypy 1.14.1, subprocess-encoding, brand, harness parity, sync-io-in-async, lockdown-before-publish — all green.

Pattern harvest

Rule candidate: a *_for_update reader's strictness must cover every transform between the read and the write (root type, row shape, coercion round-trip), not just the parse — a lenient normalizer downstream of a strict reader reintroduces the same silent loss one call later.
Rule candidate: an exception raised from a credential store must carry no document content anywhere reachable — message, doc, __cause__, or __context__; raise outside the except block to sever the chain.
Class: lenient-read-feeding-whole-file-rewrite (#7620, #7788, #7794, #7805; remainder tracked in #7789).

@bolichen97
bolichen97 requested a review from a team as a code owner September 3, 2026 05:23
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of 9b250159c1157eeff78340c01fdb737a4c05bc19 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: PASS

Refuse-corrupt-instead-of-rewrite is the correct root-cause fix, applied uniformly with the display/mutation asymmetry, caller audit, and fail-closed authority read all deliberate.

[DESIGN-REVIEWED] 9b25015

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 9b250159c1157eeff78340c01fdb737a4c05bc19 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All mechanical checks are done. Composing the review.

First-Principles-Verdict: CONCERNS

Everything earns its place except read_authority's generalized signature (1 consumer, always the same key) and an undeclared non-boolean authority refusal riding along.

What this change ships

Intent: stop four read-modify-write stores from silently destroying a corrupt-but-recoverable file — a FIX.

  1. Four update readers refuse corrupt/non-UTF-8/wrong-root stores instead of rewriting — justified (Four merged update readers replace a corrupt file instead of refusing; secrets.py first #7805, merged fix(ops-mission-control): never publish the incident index or app config over a failed read #7794 precedent)
  2. Share mutations refuse rows the retention pass would silently drop — justified (measured 3-row loss)
  3. Secret-store mutations refuse lossy entries; refusals carry no document bytes — justified (credential boundary)
  4. Mutation routes answer coded 500s instead of blaming the client — justified (JSONDecodeError-under-ValueError trap; AGENTS.md code rule)
  5. Library list degrades to reconciled: false with repair wording — justified
  6. Display reads now tolerate non-UTF-8 and log every degradation — declared, justified
  7. Corrupt policy file now refuses primary-tier authority instead of granting it — justified (permissive-default fail-open)
  8. New policy_store.read_authority(key, default) — one consumer, generalized
  9. Non-boolean primary_instance now refuses authority (string "false" no longer grants) — undeclared rider, harm named
  10. Sibling-divergence docstrings closed; 2 test files graduated from the black baseline — rides along, allowed

Watch

  • read_authority is a general authority-read API with exactly 1 consumer (rotation.py:667, grep read_authority in src/: 1 non-test call site) always passing (PRIMARY_KEY, True); by the author's own catalog PRIMARY_KEY is the only permissive-default operator key, so the key/default/OPERATOR_ONLY_KEYS-guard surface anticipates callers that cannot exist today.
  • Item 9 is a real behavior change (bool("false") previously granted authority) mentioned nowhere in the description — declare it or split it.
  • Sibling accounting checks out: grep except (FileNotFoundError, json.JSONDecodeError) feeding a whole-file rewrite leaves exactly 1 unfixed sibling, aws_control/backend/backup.py:154_locked_state_update, declared and tracked in Three remaining lenient-read-feeding-whole-file-rewrite sites, and a ratchet to close the class #7789 (accepted-and-deferred). The 13 dashboard/handlers/mcp.py hits skip without writing; mochi's reader is already strict.

Subtractions

  • Shrink policy_store.read_authority(key, default) to a zero-argument strict primary read (or fold it into rotation.is_primary): 1 consumer ever passes 1 value (rotation.py:667), which deletes the KeyError guard, its pragma: no cover, and the unused default parameter.

[FIRST-PRINCIPLES-REVIEWED] 9b25015

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 9b250159c1157eeff78340c01fdb737a4c05bc19 — this comment is updated in place on each push.

Review details

I've verified the candidate's mechanics and the surrounding contracts directly.

Candidate 1 (secrets.py:594, null-valued provider slips the strict check). The behavior claim is correct: out.get("datadog") returns None for the dropped key, fields is the literal JSON None, and None != None is False, so no CorruptDocumentError is raised and the {"datadog": null} entry is silently dropped on the update-path rewrite. But (c) — an observable wrong outcome — does not hold at the required bar. Every value shape that carries actual data is caught and refused: a scalar string (None != "scalar" → raises), a number, a bool, an array, a non-empty object all trip the strict check; an empty {} correctly round-trips. The one and only shape that slips is literal null, which is the absence of a value — it carries no credential and no recoverable data. The purpose of the strict guard (#7805) is to prevent silent destruction of recoverable credentials; a null entry has none to destroy, and its normalization away is indistinguishable from correct behavior. The candidate itself concedes this ("the dropped shape carries no token, so the destructive harm #7805 targets does not materialize"). I could not ground a wrong outcome any operator would perceive. Scored below 80; dropped.

Step 2 review of the diff surfaced no new grounded defect. The refusal wiring is consistent: CorruptDocumentError subclasses json.JSONDecodeError (models.py:70), so every route arm catching json.JSONDecodeError and is_primary's except (OSError, ValueError) both catch it; the shares._load_for_update per-row check correctly folds KeyError/ValueError/TypeError into the damaged path; and document bytes are scrubbed from the credential store's exception (empty doc, severed chain) while the non-credential ledgers forward only truncated str(...).

No findings.

[OPUS-REVIEWED] 9b25015

Verdict parsed from the review's SHA-scoped output markers for commit 9b250159c1157eeff78340c01fdb737a4c05bc19.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 9b250159c1157eeff78340c01fdb737a4c05bc19: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 9b250159c1157eeff78340c01fdb737a4c05bc19 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 9b25015

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 9b250159c1157eeff78340c01fdb737a4c05bc19: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 3, 2026
@bolichen97
bolichen97 force-pushed the fix/refuse-corrupt-rmw-7805 branch from 15cd57f to 8d47e7f Compare September 3, 2026 05:48
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 3, 2026
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 3, 2026
@bolichen97
bolichen97 force-pushed the fix/refuse-corrupt-rmw-7805 branch from 8d47e7f to 26ca390 Compare September 3, 2026 06:44
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 3, 2026
@bolichen97

bolichen97 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Disposition: FIXED — span=9a3cae9e78a7

  • The display-read docstrings said "anything else is logged" while the wrong-root path returned empty silently at library.py, shares.py, secrets.py, and policy_store.py — fixed by bringing the code up to the docstring rather than narrowing it: all four display reads now LOG the wrong-root degradation ("root is not an object/array; … will render empty"), matching the merged store._read_index_unlocked precedent, which warns on a non-object root for the same silence-looks-like-health reason.

The secrets log line stays a fixed literal with no interpolated content and carries the same nosemgrep annotation as its in-file sibling. Verified on the current head; the pinning tests exercise each reader's wrong-root path.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 3, 2026
iamwhatever pushed a commit that referenced this pull request Sep 3, 2026
## Problem

`PinnedFilesService._reload_pins_from_disk` caught `(FileNotFoundError,
json.JSONDecodeError)` and returned with `_pins` unchanged, and all four of its
callers then `_persist()` a whole-file rewrite. A corrupt `pinned-files.json`
was therefore silently replaced by whatever the in-memory list held, discarding
the rows another process wrote that this one never loaded -- the unparseable
file was their only remaining copy.

The MCP server is the second writer of the same file and had the worse half of
the same bug: `_tool_pin_file` / `_tool_unpin_file` read through `_read_json`
with a `{"version": 1, "pins": []}` default, so a corrupt store was ZEROED
rather than merely reverted to one process's view. Fixing only the service
would have left that writer free to destroy what the service just refused to
touch, so both go through one reader.

Same lenient-read-feeding-whole-file-rewrite class as #7620, #7788, #7794 and
#7805.

## What changed

`read_pins_for_update(file_path)` is the shared update-path reader. It returns
the stored list, `None` when the file is absent (the one case where an empty
base is true, so a first pin on a fresh install still lands), and raises the
new public `PinsCorruptError` on the four shapes that all reach the same
whole-file rewrite: a parse failure, a read error the filesystem raised
(transient EACCES/EIO -- the store is still there), valid JSON whose root is
not an object or whose `pins` is not a list (no parse failure at all), and an
undecodable byte.

That last one decodes STRICTLY where `load` and the Node original use
`errors="replace"`. The leniency only surfaces as a parse failure when the bad
byte falls OUTSIDE a JSON string; inside a quoted value -- a pin label, or a
path on a filesystem that does not enforce UTF-8 -- it becomes U+FFFD,
`json.loads` SUCCEEDS, and the whole-file rewrite persists the mangled text.
Proven directly: a store holding `"label": "caf\xe9 notes"` came back from a
mutation with byte `0xe9` gone and `\ufffd` in its place. Raised by GPT 5.6
review (span=617a8d5961a6).

`load` keeps the lenient decode: it does not write, and a file it accepts is
preserved as a `.bak.<now_ms>` sidecar first. A mangled label can still reach
memory at startup; it can no longer reach disk, because every write path
re-reads through this reader.

The refusal is a named public type rather than the bare `json.JSONDecodeError`
the aws-control readers raise in #8084: that app reached for the plain type
only because the named one lived in another app, which does not apply to a type
declared in the module both writers already import. Modelled on
ops-mission-control's `CorruptDocumentError`.

`add_pin` / `remove_pin` / `mark_seen` propagate it, and the HTTP handlers map
it to `500 {"code": "pins_corrupt"}`. Returning `{"ok": false}` would read as
"no such pin", and for mark-seen -- which has no failure return -- the old
behaviour reported success while the store was being replaced. The exception
text is not echoed: pin labels and paths are agent-authored and redacted on the
way out of the GET handler.

`_process_watch_event` is the one path that swallows the refusal. It fires from
the owner's tick loop, where an escaping error would take down every other tick
(stats, watchlist, presence) over a file it merely wanted to stamp; dropping
the stamp is the same degradation a failed `_persist` already takes there. The
paths a user drove raise, so the operator still learns the store needs repair.

Refuse-only was chosen over extending the sidecar to the update path (#7789's
shape) because this file already has the sidecar where a replacement is
intended, and the update path's whole problem is that no replacement was
intended at all.

## Tests

`test/test_mochi_pinned_files_cov80.py`, 15 new tests. The mutation cases
assert on the FILE's bytes rather than the exception type, so they fail on the
buggy code for the reason that regressed; `TestRefusalIsTheNamedType` pins the
type separately.

Red-before, against pristine `origin/main` source -- 13 failed / 27 passed,
each on a behavioural assertion:

- `add_pin` rewrote the corrupt file from its in-memory list
- `remove_pin` rewrote it, landing `"pins": []`
- `mark_seen` rewrote it
- a debounced watch event rewrote it from the tick loop
- `_tool_pin_file` returned `{"ok": true, "pins": 1}` and zeroed it
- `_tool_unpin_file` zeroed it

The two UTF-8 tests were separately proven against the pre-strict-decode
revision of this branch -- 2 failed / 1 passed, the mutation case on the raw
bytes.

Gates: 612 tests green (all 17 `test_mochi_*` files plus the black-gate
contract), black, isort, flake8, mypy (23 files), sync-io-in-async gate.

## Pattern harvest

Rule candidate: semgrep

Pattern: `read_text(errors="replace")` -- or any lossy decode -- on a read whose
value is written back to the same file.

A lossy decode is a repair, and a repair is only safe on a path that does not
persist what it read. On a read-modify-write path the substituted character is
written over the original bytes, so the loss is silent and irreversible -- and,
uniquely among the corruption shapes, it produces VALID JSON, so no
parse-failure clause downstream can catch it. The same file legitimately keeps
the lenient decode on its display/startup path, which is why the rule has to
key on the read reaching a writer rather than on the decode call alone.

This is the fifth instance of the enclosing class (#7620, #7788, #7794, #7805,
this PR), and the decode variant is the one the earlier four did not cover --
they raised on `UnicodeDecodeError` because they decoded strictly to begin with.

Closes #8088
@bolichen97
bolichen97 force-pushed the fix/refuse-corrupt-rmw-7805 branch 2 times, most recently from d9e5873 to 3263496 Compare September 3, 2026 07:49
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 3, 2026
iamwhatever added a commit that referenced this pull request Sep 3, 2026
#8092)

## Problem

`PinnedFilesService._reload_pins_from_disk` caught `(FileNotFoundError,
json.JSONDecodeError)` and returned with `_pins` unchanged, and all four of its
callers then `_persist()` a whole-file rewrite. A corrupt `pinned-files.json`
was therefore silently replaced by whatever the in-memory list held, discarding
the rows another process wrote that this one never loaded -- the unparseable
file was their only remaining copy.

The MCP server is the second writer of the same file and had the worse half of
the same bug: `_tool_pin_file` / `_tool_unpin_file` read through `_read_json`
with a `{"version": 1, "pins": []}` default, so a corrupt store was ZEROED
rather than merely reverted to one process's view. Fixing only the service
would have left that writer free to destroy what the service just refused to
touch, so both go through one reader.

Same lenient-read-feeding-whole-file-rewrite class as #7620, #7788, #7794 and
#7805.

## What changed

`read_pins_for_update(file_path)` is the shared update-path reader. It returns
the stored list, `None` when the file is absent (the one case where an empty
base is true, so a first pin on a fresh install still lands), and raises the
new public `PinsCorruptError` on the four shapes that all reach the same
whole-file rewrite: a parse failure, a read error the filesystem raised
(transient EACCES/EIO -- the store is still there), valid JSON whose root is
not an object or whose `pins` is not a list (no parse failure at all), and an
undecodable byte.

That last one decodes STRICTLY where `load` and the Node original use
`errors="replace"`. The leniency only surfaces as a parse failure when the bad
byte falls OUTSIDE a JSON string; inside a quoted value -- a pin label, or a
path on a filesystem that does not enforce UTF-8 -- it becomes U+FFFD,
`json.loads` SUCCEEDS, and the whole-file rewrite persists the mangled text.
Proven directly: a store holding `"label": "caf\xe9 notes"` came back from a
mutation with byte `0xe9` gone and `\ufffd` in its place. Raised by GPT 5.6
review (span=617a8d5961a6).

`load` keeps the lenient decode: it does not write, and a file it accepts is
preserved as a `.bak.<now_ms>` sidecar first. A mangled label can still reach
memory at startup; it can no longer reach disk, because every write path
re-reads through this reader.

The refusal is a named public type rather than the bare `json.JSONDecodeError`
the aws-control readers raise in #8084: that app reached for the plain type
only because the named one lived in another app, which does not apply to a type
declared in the module both writers already import. Modelled on
ops-mission-control's `CorruptDocumentError`.

`add_pin` / `remove_pin` / `mark_seen` propagate it, and the HTTP handlers map
it to `500 {"code": "pins_corrupt"}`. Returning `{"ok": false}` would read as
"no such pin", and for mark-seen -- which has no failure return -- the old
behaviour reported success while the store was being replaced. The exception
text is not echoed: pin labels and paths are agent-authored and redacted on the
way out of the GET handler.

`_process_watch_event` is the one path that swallows the refusal. It fires from
the owner's tick loop, where an escaping error would take down every other tick
(stats, watchlist, presence) over a file it merely wanted to stamp; dropping
the stamp is the same degradation a failed `_persist` already takes there. The
paths a user drove raise, so the operator still learns the store needs repair.

Refuse-only was chosen over extending the sidecar to the update path (#7789's
shape) because this file already has the sidecar where a replacement is
intended, and the update path's whole problem is that no replacement was
intended at all.

## Tests

`test/test_mochi_pinned_files_cov80.py`, 15 new tests. The mutation cases
assert on the FILE's bytes rather than the exception type, so they fail on the
buggy code for the reason that regressed; `TestRefusalIsTheNamedType` pins the
type separately.

Red-before, against pristine `origin/main` source -- 13 failed / 27 passed,
each on a behavioural assertion:

- `add_pin` rewrote the corrupt file from its in-memory list
- `remove_pin` rewrote it, landing `"pins": []`
- `mark_seen` rewrote it
- a debounced watch event rewrote it from the tick loop
- `_tool_pin_file` returned `{"ok": true, "pins": 1}` and zeroed it
- `_tool_unpin_file` zeroed it

The two UTF-8 tests were separately proven against the pre-strict-decode
revision of this branch -- 2 failed / 1 passed, the mutation case on the raw
bytes.

Gates: 612 tests green (all 17 `test_mochi_*` files plus the black-gate
contract), black, isort, flake8, mypy (23 files), sync-io-in-async gate.

## Pattern harvest

Rule candidate: semgrep

Pattern: `read_text(errors="replace")` -- or any lossy decode -- on a read whose
value is written back to the same file.

A lossy decode is a repair, and a repair is only safe on a path that does not
persist what it read. On a read-modify-write path the substituted character is
written over the original bytes, so the loss is silent and irreversible -- and,
uniquely among the corruption shapes, it produces VALID JSON, so no
parse-failure clause downstream can catch it. The same file legitimately keeps
the lenient decode on its display/startup path, which is why the rule has to
key on the read reaching a writer rather than on the decode call alone.

This is the fifth instance of the enclosing class (#7620, #7788, #7794, #7805,
this PR), and the decode variant is the one the earlier four did not cover --
they raised on `UnicodeDecodeError` because they decoded strictly to begin with.

Closes #8088

Co-authored-by: Joe Guo <zejiangg@amazon.com>
@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Sep 3, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator Author

Open PR relationship audit

This is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion.

Relationship findings

  • This PR is OVERLAPPING with PR #4951. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #8084: REBASE. Complementary work in the same two modules with different goals; neither blocks the other. Files: src/kiro_crew/apps/builtins/ops_mission_control/backend/policy_store.py, src/kiro_crew/apps/builtins/ops_mission_control/backend/rotation.py.
  • This PR is OVERLAPPING with PR #8236. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #8084: REBASE. Different user goals in the same file with no shared behavior. Both should land; whichever merges second rebases the import and route-registration regions. Files: src/kiro_crew/apps/builtins/aws_control/backend/routes.py. The two independent directions used different labels; the matrix conservatively retains OVERLAPPING for coordination.
  • This PR is OVERLAPPING with PR #8248. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #8084: REBASE. Same theme, opposite and non-conflicting halves of it, disjoint code. Files: src/kiro_crew/memory.py. The two independent directions used different labels; the matrix conservatively retains OVERLAPPING for coordination.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

…ad of replacing it

The four merged *_for_update readers -- shares._load_for_update,
library._read_ledger_for_update, KeystoneFileBackend._read_for_update and
policy_store._read_for_update -- caught json.JSONDecodeError alongside
FileNotFoundError and returned an empty container, so a corrupt or truncated
file was silently treated as empty and then rewritten whole, destroying bytes
a person could still have recovered by hand. secrets.py is the copy that
matters most: it is the only copy of every provider token on the box.

Each reader now lets corruption propagate, mirroring the #7794 decision for
the incident index and app config: only a MISSING file reads as empty. The
non-UTF-8 byte stream (UnicodeDecodeError, a ValueError that is NOT a
JSONDecodeError) and the valid-JSON-wrong-root shape are folded into the same
refusal, and the secrets coercion refuses anything that would not survive a
read-write cycle. The ops-mission-control pair raises the named
CorruptDocumentError; the aws_control pair raises plain json.JSONDecodeError
because the named type lives in another app and these readers pass rows
through verbatim.

Every caller of the four mutation paths was audited for the
JSONDecodeError-under-ValueError trap the issue names: the library push and
remove routes gained a corruption arm AHEAD of their tolerant ValueError arms,
the share mint and forget routes answer a coded 500 instead of a bare one (a
minted-but-unrecorded URL is withheld), the reconcile caller degrades to
not-reconciled and keeps rendering, and the secret save/revocation routes
answer the shared _store_read_refusal mapper's coded 500. Display and lookup
reads stay lenient -- that asymmetry is the point -- and now log when they
degrade for any reason other than an absent file.

Closes #7805

Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
@bolichen97
bolichen97 force-pushed the fix/refuse-corrupt-rmw-7805 branch from 3263496 to 9b25015 Compare September 4, 2026 07:24
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: passed Eligible automated validation passed for the current revision labels Sep 4, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator Author

[operator: bolichen97] Rebased onto current main (1f3abc09) to clear the merge conflict; force-pushed 3263496039b250159c.

Sole conflict was in aws_control/backend/routes.py::_handle_drive_share: main had since moved the exists/presign/record_share sequence inside the _locked_drive_write critical section under a single except AWSError. This PR's share_ledger_corrupt intent was written against the pre-lock structure. Resolved by grafting the intent onto main's newer locked form — added an except json.JSONDecodeError arm to that same try/lock block, so a corrupt share ledger on record_share still withholds the minted URL and returns the coded 500 share_ledger_corrupt rather than leaking an unrevokable bearer grant. The URL-withholding guarantee is preserved (the early return happens before web.json_response), and the mint's local exists-check moved inside the lock (main's race fix) is left intact.

All other 15 files auto-merged clean. Local gates on the rebased head: black-baseline, isort, flake8 7.1.0, mypy 1.14.1 (1279 files) green; targeted tests test_aws_control_{app,routes,library}.py + full ops_mission_control suite = 1310 passed / 44 skipped (-n 4).

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: checking Automated validation is still running labels Sep 4, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • read_authority(key, default) generalized signature (1 consumer)rebutted (disproportional to change).

read_authority is a general authority-read API with exactly 1 consumer always passing (PRIMARY_KEY, True); shrink it to a zero-argument strict primary read.

The (key, default) signature is deliberately the matched read half of the operator-only keystore's read/write pair: put(key, value) sits directly below it (policy_store.py:388) with the identical if key not in OPERATOR_ONLY_KEYS: raise KeyError guard, and both mirror the module's existing get(key, default) shape. Collapsing read_authority to a zero-arg read_primary() would make the strict authority reader asymmetric with its own writer and with get, and would bury the OPERATOR_ONLY_KEYS guard that documents the security boundary — the guard is what states in code that only these keys may bypass config.json, and it is not dead: it is the same invariant put enforces. The default parameter is not unused speculation either; it distinguishes a genuinely-missing file (fresh install → default state) from a corrupt one (refuse), which is the whole point of the strict/lenient split this PR draws. The finding is advisory (CONCERNS, not BLOCK) and names no reachable defect; the shipped shape is the honest one for a keystore accessor pair, so the code stays as-is.

@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • Item 9: non-boolean primary_instance refusal is an undeclared riderrebutted (it is declared).

Item 9 is a real behavior change (bool("false") previously granted authority) mentioned nowhere in the description — declare it or split it.

It is a real behavior change, and it is declared — it is not a rider but the core of the widened-door fix this PR was driven to close. The PR description names it explicitly: "The one exception, primary_instance (defaults True, so a degraded read GRANTS ledger-prune authority — the corrupt file becoming the key that unlocks destroying shared knowledge), no longer reads through the lenient path at all: rotation.is_primary now uses the new strict policy_store.read_authority, which reuses the update reader's corruption doors and answers False when the file cannot be read. Two GPT lane rounds drove this." The read_authority docstring (policy_store.py:360-382) states the same rationale. So the finding's premise — that the change is undocumented — does not hold; no split is needed, and the behavior (a corrupt/non-boolean authority value refuses rather than grants) is the intended fail-closed outcome, not a surprise. Advisory CONCERNS, no reachable defect; code unchanged.

@bolichen97
bolichen97 enabled auto-merge (squash) September 4, 2026 17:06
@bolichen97
bolichen97 merged commit 87f149c into main Sep 4, 2026
66 of 72 checks passed
@bolichen97
bolichen97 deleted the fix/refuse-corrupt-rmw-7805 branch September 4, 2026 17:07

@chenmingwei23 chenmingwei23 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving: PR Readiness green (the repo's only required check), no failing lanes, MERGEABLE.

@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 4, 2026
DeryFerd added a commit to DeryFerd/KiroCrew that referenced this pull request Sep 5, 2026
… read

All three mutations rewrite the whole ledger from what they read, and
they stood on the lenient display reader: a transient EACCES/EIO or a
truncated document read as empty and was then published back --
record_install over every other app's refcount, and
classify_and_clean_for_uninstall as an outright wipe of the only
record that an installed dependency is still referenced. The sidecar
lock serializes writers and says nothing about a read that failed.

_read_ledger_for_update is the mutation base: only a MISSING file
reads as empty; an unreadable or corrupt document (and a root that
parses but is not an object) propagates and the mutation is abandoned.
UnicodeDecodeError is folded into the refusal: it is a ValueError but
not a json.JSONDecodeError, so unwrapped it slips past corruption
clauses. The display reader stays lenient -- and gains the object-root
check it lacked, so a non-object ledger degrades to empty with a loud
log instead of crashing every lookup with AttributeError.

Because the strict read fires after the uninstall's irreversible steps,
the uninstall handler pre-flights it before anything destructive and
returns a handled dependency_ledger_unreadable refusal instead of
stranding a half-uninstalled app whose retry reruns teardown -- the
same shape as its trust-grant precondition, offloaded to the executor.
A keep_dependencies purge never touches the ledger and skips the
pre-flight. Mirrors the merged kirodotdev#8084 readers (kirodotdev#7805 class).
DeryFerd added a commit to DeryFerd/KiroCrew that referenced this pull request Sep 5, 2026
… read

All three mutations rewrite the whole ledger from what they read, and
they stood on the lenient display reader: a transient EACCES/EIO or a
truncated document read as empty and was then published back --
record_install over every other app's refcount, and
classify_and_clean_for_uninstall as an outright wipe of the only
record that an installed dependency is still referenced. The sidecar
lock serializes writers and says nothing about a read that failed.

_read_ledger_for_update is the mutation base: only a MISSING file
reads as empty; an unreadable or corrupt document (and a root that
parses but is not an object) propagates and the mutation is abandoned.
UnicodeDecodeError is folded into the refusal: it is a ValueError but
not a json.JSONDecodeError, so unwrapped it slips past corruption
clauses. The display reader stays lenient -- and gains the object-root
check it lacked, so a non-object ledger degrades to empty with a loud
log instead of crashing every lookup with AttributeError.

Each flow surfaces the refusal in its own contract instead of tearing
through it. The uninstall handler pre-flights the read before anything
destructive and returns a handled dependency_ledger_unreadable refusal
-- the same shape as its trust-grant precondition, offloaded to the
executor; a keep_dependencies purge never touches the ledger and skips
it. The install resolver reports a refused record as a failed
dependency (the install did happen; a retry after the ledger is
repaired re-records it), and the dep-cleanup loop logs and continues so
one unreadable read cannot strand the rest of the uninstall.
Mirrors the merged kirodotdev#8084 readers (kirodotdev#7805 class).
DeryFerd added a commit to DeryFerd/KiroCrew that referenced this pull request Sep 5, 2026
… read

All three mutations rewrite the whole ledger from what they read, and
they stood on the lenient display reader: a transient EACCES/EIO or a
truncated document read as empty and was then published back --
record_install over every other app's refcount, and
classify_and_clean_for_uninstall as an outright wipe of the only
record that an installed dependency is still referenced. The sidecar
lock serializes writers and says nothing about a read that failed.

_read_ledger_for_update is the mutation base: only a MISSING file
reads as empty; an unreadable or corrupt document (and a root that
parses but is not an object) propagates and the mutation is abandoned.
UnicodeDecodeError is folded into the refusal: it is a ValueError but
not a json.JSONDecodeError, so unwrapped it slips past corruption
clauses. The display reader stays lenient -- and gains the object-root
check it lacked, so a non-object ledger degrades to empty with a loud
log instead of crashing every lookup with AttributeError.

Each flow surfaces the refusal in its own contract instead of tearing
through it. The uninstall handler pre-flights the read before anything
destructive and returns a handled dependency_ledger_unreadable refusal
-- the same shape as its trust-grant precondition, offloaded to the
executor; a keep_dependencies purge never touches the ledger and skips
it. The install resolver reports a refused record as a failed
dependency (the install did happen; a retry after the ledger is
repaired re-records it), and the dep-cleanup loop logs and continues so
one unreadable read cannot strand the rest of the uninstall.
Mirrors the merged kirodotdev#8084 readers (kirodotdev#7805 class).
DeryFerd added a commit to DeryFerd/KiroCrew that referenced this pull request Sep 8, 2026
… read

All three mutations rewrite the whole ledger from what they read, and
they stood on the lenient display reader: a transient EACCES/EIO or a
truncated document read as empty and was then published back --
record_install over every other app's refcount, and
classify_and_clean_for_uninstall as an outright wipe of the only
record that an installed dependency is still referenced. The sidecar
lock serializes writers and says nothing about a read that failed.

_read_ledger_for_update is the mutation base: only a MISSING file
reads as empty; an unreadable or corrupt document (and a root that
parses but is not an object) propagates and the mutation is abandoned.
UnicodeDecodeError is folded into the refusal: it is a ValueError but
not a json.JSONDecodeError, so unwrapped it slips past corruption
clauses. The display reader stays lenient -- and gains the object-root
check it lacked, so a non-object ledger degrades to empty with a loud
log instead of crashing every lookup with AttributeError.

Each flow surfaces the refusal in its own contract instead of tearing
through it. The uninstall handler pre-flights the read before anything
destructive and returns a handled dependency_ledger_unreadable refusal
-- the same shape as its trust-grant precondition, offloaded to the
executor; a keep_dependencies purge never touches the ledger and skips
it. The install resolver reports a refused record as a failed
dependency (the install did happen; a retry after the ledger is
repaired re-records it), and the dep-cleanup loop logs and continues so
one unreadable read cannot strand the rest of the uninstall.
Mirrors the merged kirodotdev#8084 readers (kirodotdev#7805 class).
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.

Four merged update readers replace a corrupt file instead of refusing; secrets.py first

3 participants