From 4ecbf53056c791ff083ba2349dcd27154c87ee3c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 03:20:59 +0200 Subject: [PATCH] Check a declared instance against the slug grammar on the read path F6: no declared instance Slug was ever checked against Slug's own grammar before reaching Mind::require_instance's byte comparison, so a name outside the grammar read as merely a foreign mind rather than being refused by its own name. Slug's grammar check is a crate-private trait in epiphany_pipeline, reachable only through a real document, so require_grammatical_instance wraps the declared name in a throwaway PipelineInstance and reads the answer back through PipelineDocument::validate, the leaf's own public door; nothing here re-derives the grammar. Daemon::handle asks it on the read path, before Mind::require_instance, for every request that names an instance. Add a_declared_instance_outside_the_grammar_is_refused_by_name_before_the_identity_check, driving a fullwidth Latin small letter y, and mutation entry S3wide, which folds a fullwidth character to plain ASCII before validating and dies against it. Co-Authored-By: Claude Sonnet 5 --- crates/huginn-daemon/src/daemon.rs | 51 +++++++++++++++++++++++++++--- crates/huginn-mind/src/lib.rs | 2 +- crates/huginn-mind/src/mind.rs | 28 +++++++++++++++- tools/eureka-cut10-mutations.psd1 | 37 ++++++++++++++++++++++ 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index fcbce14..3570c25 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -58,14 +58,20 @@ impl> Daemon { /// One request, one answer. `Admit` goes to the mind whole, because the /// batch declares its own instance and admission's A1 is the check. Every - /// other request names an instance, and the mind is asked whether that is - /// its own before the method runs. + /// other request names an instance: the read path asks the leaf whether + /// that name is even grammatical before asking the mind whether it is its + /// own, so a name outside the grammar (a fullwidth fold, say) is refused + /// by name rather than read as merely a foreign mind. pub fn handle(&mut self, request: HuginnMindRequest, now: DateTime) -> HuginnMindResponse { if let Some(declared) = request.instance() && !matches!(request, HuginnMindRequest::Admit(_)) - && let Err(refusal) = self.mind.require_instance(declared) { - return HuginnMindResponse::Refused(refusal); + if let Err(refusal) = huginn_mind::require_grammatical_instance(declared) { + return HuginnMindResponse::Refused(refusal); + } + if let Err(refusal) = self.mind.require_instance(declared) { + return HuginnMindResponse::Refused(refusal); + } } match request { HuginnMindRequest::Whoami => HuginnMindResponse::Whoami(self.mind.status()), @@ -484,6 +490,43 @@ pub(crate) mod tests { assert_eq!(status(&mut daemon).documents, 1, "nothing landed"); } + /// A declared instance outside `Slug`'s grammar is refused by its own + /// name, `InvalidFormat`, on the read path, before the identity + /// comparison ever runs: it is not merely a foreign mind, it names no + /// mind a client could compose. A fullwidth Latin small letter y is used + /// because it is the shape Soul named: a check that folds it to plain + /// ASCII `y` before validating would let this probe pass as grammatical + /// and reach `require_instance`, which would then answer `ForeignInstance` + /// instead — the wrong refusal, reached the wrong way. + #[test] + fn a_declared_instance_outside_the_grammar_is_refused_by_name_before_the_identity_check() { + let (_root, mut daemon) = seeded(); + let fullwidth = "\u{FF59}ggdrasil"; + assert_ne!(fullwidth, INSTANCE, "not the mind's own bytes"); + assert!(!fullwidth.is_ascii(), "outside the grammar: a Slug is dot-joined ASCII labels"); + + let expected = MindRefusal::Document(huginn_mind::epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "instance.instance".into(), + value: fullwidth.into(), + }); + + let query = HuginnMindRequest::Query { instance: slug(fullwidth), query: PipelineQuery::default() }; + assert_eq!(daemon.handle(query, now()), HuginnMindResponse::Refused(expected.clone())); + + let view = HuginnMindRequest::View { + instance: slug(fullwidth), + id: PipelineRef { kind: PipelineKind::Question, id: Short("not-reached".into()) }, + }; + assert_eq!(daemon.handle(view, now()), HuginnMindResponse::Refused(expected.clone())); + + let items = HuginnMindRequest::OpenItems { instance: slug(fullwidth), campaign: slug(CAMPAIGN) }; + assert_eq!(daemon.handle(items, now()), HuginnMindResponse::Refused(expected.clone())); + + let scope = HistoryScope::Repo(OrgRepo("GameCult/Huginn".into())); + let history = HuginnMindRequest::History { instance: slug(fullwidth), scope }; + assert_eq!(daemon.handle(history, now()), HuginnMindResponse::Refused(expected)); + } + /// A refusal a read method raised is the answer the dispatch returns, whole. /// The instance check refuses before the method runs and is pinned above; /// these are the refusals the methods themselves raise, so nothing here is diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index 55d92c6..7e47924 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -36,7 +36,7 @@ pub mod wire; pub(crate) mod fixtures; pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome}; -pub use mind::{HuginnMindEpoch, Mind}; +pub use mind::{HuginnMindEpoch, Mind, require_grammatical_instance}; pub use query::{ AdmissionFacts, HistoryScope, PipelineDocumentView, PipelineOpenItems, PipelineQuery, PipelineQueryPage, PipelineStatus, QUERY_LIMIT_MAX, SemanticQuery, diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index 5fda6bf..0406a25 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -13,7 +13,8 @@ use std::path::{Path, PathBuf}; use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore}; use epiphany_pipeline::{ - PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineKind, Slug, register_pipeline_document_types, + Date, PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineInstance, PipelineKind, Short, Slug, + register_pipeline_document_types, }; use crate::receipt::HuginnCommitReceipt; @@ -48,6 +49,31 @@ pub(crate) fn unavailable(error: anyhow::Error) -> MindRefusal { MindRefusal::Unavailable { detail: format!("{error:#}") } } +/// A declared instance's grammar, checked before its bytes are compared to +/// anything. `Slug` is `epiphany_pipeline`'s, dot-joined ASCII labels, and the +/// leaf validates one only as a document field: its grammar check is a +/// crate-private trait, so a bare `Slug` cannot be asked directly and the only +/// public door is a real document. This wraps the declared name in a +/// throwaway `PipelineInstance` and reads the answer back through the leaf's +/// own public `PipelineDocument::validate`, so the grammar is the leaf's, +/// never re-derived here: a fold that widens what counts as ASCII (a +/// fullwidth character folded to its plain form, say) has nothing local to +/// weaken, because nothing local decides the grammar. +/// +/// Read-path callers ask this before `Mind::require_instance`, so a name +/// outside the grammar is refused by its own name — the leaf's +/// `InvalidFormat` — rather than reaching the identity comparison and being +/// read as merely a foreign mind. +pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal> { + let probe = PipelineDocument::Instance(PipelineInstance { + instance: declared.clone(), + display_name: Short("grammar-probe".into()), + created_at: Date("2026-01-01".into()), + host: Short("grammar-probe".into()), + }); + probe.validate().map_err(MindRefusal::Document) +} + /// A cache that knows the fifteen types a mind's store may hold: the leaf's /// thirteen through its registrar, the epoch record and the commit receipt. /// It is the cache every envelope is prepared against. diff --git a/tools/eureka-cut10-mutations.psd1 b/tools/eureka-cut10-mutations.psd1 index a086c3c..43f85fb 100644 --- a/tools/eureka-cut10-mutations.psd1 +++ b/tools/eureka-cut10-mutations.psd1 @@ -143,6 +143,43 @@ Old = ' if declared != self.instance() {' New = ' if !declared.0.eq_ignore_ascii_case(&self.instance().0) {' } + @{ + Id = 'S3wide' + Rule = 'The grammar check runs on the declared bytes as given: nothing folds a fullwidth character to its plain ASCII form before asking the leaf whether the name is grammatical.' + Test = 'daemon::tests::a_declared_instance_outside_the_grammar_is_refused_by_name_before_the_identity_check' + Command = 'cargo test -p huginn-daemon --lib' + File = 'crates/huginn-mind/src/mind.rs' + Old = @' +pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal> { + let probe = PipelineDocument::Instance(PipelineInstance { + instance: declared.clone(), + display_name: Short("grammar-probe".into()), + created_at: Date("2026-01-01".into()), + host: Short("grammar-probe".into()), + }); + probe.validate().map_err(MindRefusal::Document) +} +'@ + New = @' +pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal> { + let folded: String = declared + .0 + .chars() + .map(|c| match c as u32 { + 0xFF01..=0xFF5E => char::from_u32(c as u32 - 0xFEE0).unwrap_or(c), + _ => c, + }) + .collect(); + let probe = PipelineDocument::Instance(PipelineInstance { + instance: Slug(folded), + display_name: Short("grammar-probe".into()), + created_at: Date("2026-01-01".into()), + host: Short("grammar-probe".into()), + }); + probe.validate().map_err(MindRefusal::Document) +} +'@ + } @{ Id = 'D2' Rule = 'Rulings 14 and 18 on the wire: the daemon passes the declared instance to the mind as the client sent it.'