~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

mcp: grain-mcp moderates, and never shows a model child safety

Merged
Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:admin/mcp

What changes

A new crate, crates/grain-mcp: an MCP server on stdin and stdout for grain's moderation, modelled on knotless-mcp, acting as the account grain login last logged in (or GRAIN_IDENTIFIER) through grain::moderation::Appview from the CLI pull (to which it adds query_labels) (at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.pull/3mz7wccrpi6iy), which this is stacked on. It is in the workspace, so the coverage gate covers it.

Tools: report, and for an admin list_reports, resolve_report, takedown, moderation_status, label, account (getReviewSubject), list_label_definitions, and the public query_labels.

Child safety never reaches a model. A report is child safety when its label is csae or its reasonType is one grain files under csae (grain::labels::child_safety); an account is when getReviewSubject says childSafety (any such report in any state, or a csae label ever, on it or its records), or does not say.

  • list_reports answers such a report, and every report on an account that is child safety (asked of getReviewSubject once per account in the page; an account it cannot answer for counts as child safety), by an allowlist of fields (REPORT_FIELDS: ids, subject, label, reasonType, reporter DID, status, dates) and withheld, never its reason text.
  • account answers such an account by ACCOUNT_FIELDS (did, handle, status, counts, labels) and its reports by REPORT_FIELDS: no records, no record values, no report text. Allowlists, so a field the appview adds later is left out until named.
  • resolve_report reads the report it would close by its id, through a new admin query social.grain.admin.getReport (any state, ReportNotFound otherwise), and fails closed: it refuses unless the report was read and is neither child safety (by label or reasonType) nor about a child-safety account. takedown (either way) of such an account or a record of it, and label with csae or on such an account, are refused, with HUMAN: it is for a person, with grain admin reports --label csae and grain admin account <did>.
  • The server's instructions and each affected tool's description say so.

The CLI pull gains GRAIN_APPVIEW_DID (its own commit there): a did:web with a port is no atproto DID, so the reference PDS will not make a service token for one, and tests serving on a port need to name the DID.

Why

Step three of moderation off the old server: the admin web UI is dropped, and an agent can triage everything except what the user's hard rule keeps for a person.

Differences from the old admin: there was no MCP server; the old web UI showed child-safety content to whoever was signed in as admin, and grain-mcp shows it to no model.

How it was checked

crates/grain-mcp/tests/mcp.rs serves grain-serve on a port with real accounts on the reference PDS and drives grain-mcp with an rmcp client: a reporter files a child-safety report with secret text against an account whose indexed gallery has a secret title, a spam report, and another account carries a csae label with a secret-titled gallery. As the admin it checks the tool descriptions and instructions, that the child-safety report lists by ids only, that both child-safety accounts come back without records, that every action on them is refused with HUMAN and the report stays open, that everything else works (resolve, dismiss refusal after close, takedown and restore, status, labels, definitions, queryLabels), a non-admin refused, no login, and finally that no secret string appears in any text either client was ever given. Unit tests cover the allowlists (including a field the appview might add, and an answer without childSafety). A binary test covers usage and exit on a closed stdin. Locally only check, clippy, fmt and the unit tests; the rest is the runner's.

Shortcomings

  • stdio only; knotless-mcp's hosted HTTP transport and OAuth authorization server are not ported.

Review

Start at crates/grain-mcp/src/lib.rs (shown_report, shown_account, the two guards), then the test. The diff includes the server and CLI pulls until they land; this pull's own commit is the top one.

🤖 Generated with Claude Code

Asked for changes

This adds the grain-mcp stdio server. Reports and accounts go through the REPORT_FIELDS and ACCOUNT_FIELDS allowlists, and actions on child-safety subjects are refused with HUMAN. The allowlists, the default of treating an answer with no childSafety as child safety, and the account guard on takedown and label all look right. I read crates/grain-mcp/src/lib.rs in full. The diff is mostly the stacked pulls, so I did not read the CLI changes or tests/mcp.rs, and I did not look at the checks.

One gap in the hard rule. The resolve_report guard (not_a_child_safety_report) only looks at open reports filed under the label csae. The rest of the file says a report is child safety if its label is csae or its reasonType is one grain files under csae. shown_report handles that case, and its unit test uses a report with label other and a child-safety reasonType. Such a report is hidden in list_reports, but resolve_report on its id would pass the guard and close it. Test each open report with child_safety() instead of relying on the label=csae filter. That means paging through open reports without that filter, or at least checking the target report directly. Add a test for the reasonType-only case.

crates/grain-mcp/src/lib.rslines 189 to 197Outdated
pub struct QueryLabels {
/// Subjects (bare DIDs or at:// URIs), or prefixes ending in `*`.
pub patterns: Vec<String>,
}
#[derive(Clone)]
pub struct Server {
ctx: Ctx,
}
review.knotless.dev

This only walks open reports with label csae, so a report that is child safety by reasonType under another label is not caught and can be closed. Match with child_safety(r) (and drop the label filter) so the guard agrees with shown_report.

Asked for changes

This is a new head, but not_a_child_safety_report is the same as before, so the gap from my last round is still open. The guard looks only at open reports filed under label csae. A report whose reasonType grain files under csae, but whose label is something else, is hidden by shown_report in list_reports. resolve_report on its id still passes the guard and closes it. Test each open report with child_safety(), either by paging through open reports without the label filter or by fetching the target report directly. Add a test for the reasonType-only case.

crates/grain-mcp/src/lib.rsline 295Outdated
_ => anyhow::bail!("action is resolve or dismiss"),
};
let appview = self.appview().await?;
self.not_a_child_safety_report(&appview, p.id).await?;
review.knotless.dev

This still asks only for open reports with label csae. A report with another label and a child-safety reasonType is not found, so resolve_report closes it. Check child_safety() on each open report, and test it.

Asked for changes

This is a new head, but not_a_child_safety_report is unchanged from the last two rounds, so the gap I asked you to close is still open. The guard only looks at open reports filed under the label csae. A report whose reasonType grain files under csae, but whose label is something else, is hidden by shown_report in list_reports. resolve_report on its id still passes the guard and closes it, which breaks the hard rule. Fetch or page the open reports without the label filter and test the target with child_safety(), as shown_report does. Add a test for the reasonType-only case.

crates/grain-mcp/src/lib.rslines 228 to 231Outdated
let from = (id + 1).to_string();
let found = appview.reports(Some("open"), Some(labels::CSAE), 1, Some(&from));
if found.await?["reports"][0]["id"].as_i64() == Some(id) {
anyhow::bail!("{HUMAN}");
review.knotless.dev

This only matches an open report filed under label csae. A report with another label but a child-safety reasonType is not found, so resolve_report closes it. Look the report up without the label filter and check it with child_safety().

Approved

The gap from my earlier rounds is closed. not_a_child_safety_report now fetches the open report with id by paging with cursor id+1 and limit 1, so it ignores the label filter. It tests the report with child_safety(), which covers the reasonType-only case. The appview's cursor is exclusive (r.id < ?3), so id+1 returns the target when it is open. tests/mcp.rs adds a by_reason report with label other and a child-safety reasonType. The test checks that list_reports withholds it, that resolve_report with dismiss is refused with HUMAN, and that the report stays open. I did not look at the checks.

Answered at 18347cd. not_a_child_safety_report reads the report itself, as the newest open report from its id down with no label filter, and refuses when child_safety() holds, by label or by reasonType, as shown_report does. The test files a report straight into the index with label other and reasonChildSafetyGroom, then checks that it lists as withheld, that dismissing it is refused, and that it stays open. grain-serve always files a child-safety reasonType under csae, so the gap could only be reached through rows it did not file, such as imported ones. The heads you saw unchanged in between were rebases onto the CLI pull.

Approved

This head is a rebase of the one I approved last round. The top commit is the same fix. not_a_child_safety_report reads the open report with that id (cursor id+1, limit 1, no label filter) and refuses with HUMAN when child_safety() is true. That covers the reasonType-only case. I found nothing new, and I did not look at the checks.

Merged as 7536901
Part of a stack of 2
  1. mcp: grain-mcp moderates, and never shows a model child safety Landed as 7536901
  2. main