Skip to content

Absorb decant and canary's remaining judgments; gate Mothball's destructive API - #80

Open
heznpc wants to merge 3 commits into
mainfrom
feat/absorb-decant-canary-audits
Open

Absorb decant and canary's remaining judgments; gate Mothball's destructive API#80
heznpc wants to merge 3 commits into
mainfrom
feat/absorb-decant-canary-audits

Conversation

@heznpc

@heznpc heznpc commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Consolidation work: the judgments decant and canary had that Modore did not, plus the boundary the Mothball absorption left open. Three retirements' worth of unique capability, on Modore's own contracts.

1. Hugging Face cache audit — and the defect fixed before porting it

decant's ContextProbe.isReferenced was fail-open:

guard FileManager.default.fileExists(atPath: projectsRoot) else { return false }

A missing search root, a failed grep, or a mistyped --projects all produced the same answer as an exhaustive search that found nothing. One typo could mark an entire hub cache safe to delete. decant's own cold-read audit lists this as its top critical finding.

Inverted here in one readable function, search_is_complete:

Failure decant hfscan
search root missing unreferenced unknown + search-root-missing
file cap reached n/a unknown + file-cap-reached
subtree unreadable unreferenced unknown + tree-partially-unreadable
nothing read at all unreferenced unknown + no-files-read

--allow-missing-roots / --ignore-unreadable widen the verdict only when asked. Matching is case-insensitive and substring-based on purpose: over-catching keeps a model, under-catching loses one.

2. MCP config hygiene

Reads ~/.claude.json (including per-project blocks), both Claude Desktop locations, and ~/.mcp.json; reports dead / duplicate / manual-review / unknown.

Two deliberate deviations from decant. Servers are named, not anonymised to server#N — a hygiene report the operator cannot act on is not one, and a server name is configuration metadata of the kind scree already emits. What stays hidden is what was sensitive: env is a key count, never keys or values, pinned by a test that plants a fake API key and asserts neither the value nor the key name appears. And PATH-dependent verdicts are withheld when PATH is unusable rather than reported as dead — the same fail-safe rule as above.

3. File-access reverse index

canary's get_file_access, inverting scree's evidence to path → sessions: reads, writes, shell references, session count, last touch. Rule surfaces (CLAUDE.md, AGENTS.md, settings.json, ~/.claude, ~/.codex) sort first and are the default view.

Stricter content contract than the original. canary attached a 200-character excerpt of the shell command to every row. That excerpt is command content, so it is dropped — a path extracted from a command is metadata about which file was touched; the command that touched it is not. Pinned: a transcript containing grep -r 'hunter2' /tmp/ws/secrets.txt yields the path and neither hunter2 nor grep.

Two things the naive port got wrong, both caught by running it:

  • Paths are canonicalised before aggregation. The same file arrives absolute from a tool input and ~/-relative from a shell command, splitting one file across rows — 1,322 rows for 1,212 files, with ~/.claude/settings.json listed twice.
  • Dedupe is keyed on the tool_use block id, not the path. Claude streams one assistant message as multiple lines sharing a message id, which is why dedupe is needed; keying on the path also erased genuine repeat reads, turning a count of touches into a count of sessions.

4. Mothball's destructive API is now gated by mechanism

Absorbing Mothball absorbed a second deletion discipline. Modore destroys nothing without a preview-issued single-use 64-byte token, a 15-minute owner-only manifest, a remeasure at the boundary, and a receipt. ArchiveOrchestrator.archive() is careful on its own terms — refuses / and $HOME, verifies before touching the original, moves to Trash rather than unlinking — but it is an in-process call taking no token, and the vendor dependency already compiles it into the binary. Only review stood between a future try orchestrator.archive(…) in a view action and a path the approval chain never sees.

Two tests make it a mechanism: a Modore source naming ArchiveOrchestrator, Restorer, ArchiveRun, or trashItem must also carry the approval-token symbol, so a diff cannot bring one half without showing the other; and MothballService is pinned to the read-only scan-and-classify surface it uses. Wiring the archive path stays allowed — wiring it around the token does not. Both guards were verified by injecting a violation and watching them fail.

MCP surface

Three new read-only tools on the existing contract: hf_orphans, mcp_hygiene, file_access. hf_orphans restates search_complete as a top-level verdicts_withheld flag so an incomplete search cannot read as "no orphans found"; its roots argument is bounded to 8 entries and screened so a value can never be read as an option by the script it is passed to.

None of the three scripts ships inside the signed bundle — no Swift caller, so the runtime-completeness test lists them beside friction.py.

Verification

  • 505 tests pass; swift build clean; release_smoke.py clean
  • Live: 5 MCP servers all healthy; mistyped-root case returns verdicts_withheld: true through the MCP surface; 60 sessions → 1,876 paths / 208 rule surfaces, ~/.claude/settings.json at 3 writes across 10 sessions, no command text anywhere in the payload

Not in this PR

  • balance/bin/cba hard-depends on the decant CLI in 4 places — separate repo, must land before decant is archived
  • canary's list_sessions session rows and get_session_transcript are not ported; the latter is refused by Modore's no-content contract
  • MothballView.swift:107 still tells the user to run the standalone Mothball app, which goes stale when that repo is archived

Supersedes #79, closed automatically when its head branch was renamed from claude/… to the repository's feat/ convention. Same commits, same review.

heznpc added 3 commits August 19, 2026 19:56
decant's cleanup logic already duplicated Modore's storage scan, but two of
its judgments had no equivalent here: which Hugging Face models nothing on
this machine names, and which registered MCP servers cannot start. Both are
ported as stdlib-only scripts on the scree/friction pattern and exposed
through the existing read-only MCP surface.

The Hugging Face check could not be ported as written. `ContextProbe.swift`
returned "unreferenced" whenever its search failed to run -- a search root
that did not exist, a failed grep, a mistyped `--projects` argument all
produced the same answer as an exhaustive search that found nothing. One
typo could therefore mark an entire hub cache safe to delete, and Modore
saying "safe to delete" is the one sentence that has to be earned.

So the rule is inverted at the point where it is decided: absence of
evidence counts only when the search actually happened. A missing root, a
file cap reached, or an unreadable subtree now yields `unknown` for every
model with the reason named, and `--allow-missing-roots` /
`--ignore-unreadable` widen the verdict only when the operator asks for it.
`search_is_complete` is one function so the whole rule can be read at once,
and every failure path is pinned by a test. Matching is case-insensitive
and substring-based for the same reason: over-catching keeps a model,
under-catching loses one.

mcpaudit carries the same rule where it applies -- a verdict that depends
on PATH is withheld when PATH is unusable rather than reported as `dead` --
and two deliberate deviations from decant. Servers are named rather than
anonymised to `server#N`, because a hygiene report the operator cannot act
on is not a hygiene report, and a server name is configuration metadata of
the kind scree already emits. What stays hidden is what was sensitive:
`env` is a key count, never keys or values.

Neither script ships inside the signed bundle -- they have no Swift caller,
so the runtime-completeness test lists them beside friction.py, and giving
either one a view means moving it into RUNTIME_FILES.

Verified: 121 tests across the four affected suites, full suite 481 passed,
release_smoke clean. Live run on this machine -- 5 MCP servers, all healthy;
the mistyped-root case returns `verdicts_withheld: true` through the MCP
surface rather than a list of false orphans.
Two pieces of the consolidation that had no equivalent here.

canary's `get_file_access` inverts the evidence scree already reads: for
each path, how many reads, writes, and shell references it received, from
how many sessions, and when last. Rule surfaces -- CLAUDE.md, AGENTS.md,
settings.json, anything under ~/.claude or ~/.codex -- sort first and are
the default view, because a silently edited rule file is the case this
exists for. The taxonomy is carried over verbatim.

Its content contract is stricter than the original's. canary attached a
200-character excerpt of the shell command to every row; that excerpt is
command content, so it is dropped. A path extracted from a command is
metadata about which file was touched -- the command that touched it is
not. Assistant text, tool results, and non-path tool inputs are discarded
in the same pass that reads them, and nested subagent transcripts stay
unopened, matching scree's collector.

Two things the naive port got wrong, both caught by running it:

Paths are canonicalised before aggregation. The same file arrives as an
absolute path from a tool input and as `~/...` from a shell command, so
aggregating raw strings split one file across several rows -- 1,322 rows
where there were 1,212 files, with ~/.claude/settings.json appearing
twice. An inverted index that cannot put one file on one row is not doing
its job.

Dedupe is keyed on the tool_use block id, not on the path. Claude streams
one assistant message as multiple lines sharing a message id, which is
why dedupe is needed at all; keying it on the path also erased genuine
repeat reads within a session, turning a count of touches into a count of
sessions.

Separately: absorbing Mothball absorbed a second deletion discipline.
Modore destroys nothing without a preview-issued single-use token, an
owner-only manifest, a remeasure at the boundary, and a receipt.
MothballCore's `ArchiveOrchestrator.archive()` is careful on its own
terms but is an in-process call taking no token, and the vendor
dependency already compiles it into the binary -- only review stood
between a future `try orchestrator.archive(...)` in a view action and a
path the approval chain never sees.

Two tests make that a mechanism: a Modore source naming
ArchiveOrchestrator, Restorer, ArchiveRun, or trashItem must also carry
the approval-token symbol, so a diff cannot bring one half without
showing the other; and MothballService is pinned to the read-only
scan-and-classify surface it actually uses. Wiring the archive path stays
allowed -- wiring it around the token does not. Both guards were verified
by injecting a violation and watching them fail.

Verified: 505 tests pass, swift build clean, release_smoke clean. Live
run -- 60 sessions, 1,876 paths, 208 rule surfaces;
~/.claude/settings.json shows 3 writes across 10 sessions, and no command
text appears anywhere in the payload.
The archive page's copy dated from the display-first stage of the
absorption, when vendor/mothball had just been merged and Mothball was
still a live standalone product -- so "run the Mothball app separately"
was accurate advice at the time.

Archiving that repository removed the thing being pointed at, and the
copy did not follow. It now says what is actually true: there is no
archive execution anywhere yet, and when one is wired it goes through the
same preview-and-approval path as every other deletion in this app --
which the guard added alongside this now enforces rather than merely
promises.

Verified: swift build and 169 Swift tests pass, boundary guards pass.
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.

1 participant