Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/tinymemory-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and `context.md` compiler live in [`tinymemory-tools`](../tinymemory-tools).
| `Namespace`, `Segment`, `Reach` | The memory tree: where an item lives and how far a reader reaches |
| `RecallRequest`, `FetchRequest`, `ListRequest`, `ForgetTarget`, ... | Requests and responses, each with a `validate` engines call first |
| `Facet`, `ExploreRequest`, `GetRequest` | Browsing: counts per metadata facet, and reading items by id |
| `ProvenancePolicy`, `wrap_untrusted_for_agent` | Host-configured trust classification and escaped untrusted-source markers for recalled prompt context |
| `Error`, `Result` | The one error every engine returns |

`store_many`, `explore` and `get` have default implementations (one `store` at
Expand Down Expand Up @@ -87,3 +88,7 @@ Architecture documents live in
[operation semantics](../../docs/architecture/operations.md) and
[namespaces](../../docs/architecture/namespaces.md). The accepted behaviour is
[`docs/specs/memory-v2.md`](../../docs/specs/memory-v2.md).

The [provenance module](src/provenance/README.md) describes safe prompt markers
and host-supplied connector prefixes; its accepted contract is
[Provenance markers](../../docs/specs/provenance-markers.md).
6 changes: 6 additions & 0 deletions crates/tinymemory-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
//! [`EngineDescriptor`]; a fetch mode it does not list fails with
//! [`Error::Unsupported`].
//!
//! [`ProvenancePolicy`] classifies recalled external text using host-supplied
//! connector prefixes; [`wrap_untrusted_for_agent`] renders escaped provenance
//! markers for prompt composition ([`provenance`]).
//!
//! This crate performs no I/O. Engines live in `tinymemory-integrations`
//! (the CortexDB engine, and the registry that builds one from
//! configuration).
Expand Down Expand Up @@ -57,6 +61,7 @@ pub mod explore;
pub mod item;
pub mod meta;
pub mod namespace;
pub mod provenance;
Comment thread
senamakel marked this conversation as resolved.
pub mod query;
pub mod write;

Expand All @@ -75,6 +80,7 @@ pub use meta::{
MemoryMeta, MetaFilter, ObservedActor, SourceKind, SourceRef, ToolCallRef, TurnRange,
};
pub use namespace::{Namespace, Reach, Segment, SegmentKind};
pub use provenance::{ProvenancePolicy, wrap_untrusted_for_agent};
pub use query::{
Citation, EraseReport, EraseRequest, ExportPage, Exported, FetchMode, FetchPage, FetchRequest,
ForgetReport, ForgetTarget, Hit, ListPage, ListRequest, RecallAnswer, RecallRequest, TimeHint,
Expand Down
7 changes: 7 additions & 0 deletions crates/tinymemory-api/src/provenance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Recalled context provenance

`ProvenancePolicy` classifies legacy namespace/key pairs conservatively. Hosts supply connector prefixes; the contract owns the locally authored namespace families. Unknown namespaces are untrusted, and a connector key prefix overrides a locally authored namespace.

`wrap_untrusted_for_agent` renders external context inside one escaped marker. The bounded hint is an ASCII identifier; content cannot inject a closing tag. The marker conveys provenance, not a guarantee that a model ignores instructions inside it.

These are data-only contract operations. They do not depend on an engine implementation or change stored memory metadata. The accepted behavior is in [the spec](../../../../docs/specs/provenance-markers.md).
71 changes: 71 additions & 0 deletions crates/tinymemory-api/src/provenance/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! Data-only provenance policy and explicit markers for recalled external text.
//!
//! Hosts supply their own connector-prefix catalogue. This module does not
//! read memory, migrate metadata, or infer that a marker makes content safe.

/// Conservative legacy provenance rules with a host-supplied connector list.
///
/// Unknown namespaces are untrusted. Known connector prefixes override a
/// locally authored namespace; an absent namespace and unmatched key retain
/// the legacy locally authored interpretation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvenancePolicy {
connector_prefixes: Vec<String>,
}

impl ProvenancePolicy {
/// Builds a policy from the host's connector key prefixes.
pub fn new(prefixes: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
Self {
connector_prefixes: prefixes
.into_iter()
.map(|prefix| prefix.as_ref().to_ascii_lowercase())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply case folding to non-ASCII connector prefixes

When a host supplies a non-ASCII prefix such as Émail:, to_ascii_lowercase() leaves it distinct from a key beginning with émail:, so a local or absent namespace is classified as trusted despite the contract promising case-insensitive matching. Normalize both prefixes and keys with Unicode-aware casing, or explicitly validate and document that connector prefixes must be ASCII.

Useful? React with 👍 / 👎.

.collect(),
}
}

/// Whether an entry should be marked untrusted before entering a prompt.
#[must_use]
pub fn is_potentially_untrusted(&self, namespace: Option<&str>, key: &str) -> bool {
if let Some(namespace) = namespace {
let namespace = namespace.trim().to_ascii_lowercase();
if !is_locally_authored_namespace(&namespace) {
return true;
}
}
let key = key.to_ascii_lowercase();
self.connector_prefixes
.iter()
.any(|prefix| key.starts_with(prefix))
}
}

fn is_locally_authored_namespace(namespace: &str) -> bool {
matches!(
namespace,
"working" | "agent" | "local" | "core" | "global" | "default" | "user"
) || namespace.starts_with("working.")
|| namespace.starts_with("agent.")
|| namespace.starts_with("tree.")
}

/// Renders external context inside one explicit untrusted-source marker.
///
/// Ampersands and angle brackets in content are escaped so content cannot
/// terminate its own marker. The hint contains at most 64 ASCII alphanumeric,
/// hyphen, underscore, dot or colon characters and defaults to `external`.
#[must_use]
pub fn wrap_untrusted_for_agent(content: &str, source_hint: &str) -> String {
let hint: String = source_hint
.trim()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'))
.take(64)
.collect();
let hint = if hint.is_empty() { "external" } else { &hint };
let content = content
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
format!("<untrusted-source source=\"{hint}\">\n{content}\n</untrusted-source>")
}
34 changes: 34 additions & 0 deletions crates/tinymemory-api/tests/provenance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//! Public provenance policy and safe prompt-marker regressions.
use tinymemory_api::{ProvenancePolicy, wrap_untrusted_for_agent};

#[test]
fn connector_prefixes_are_supplied_by_the_host_and_unknown_namespaces_are_untrusted() {
let policy = ProvenancePolicy::new(["email:", "slack:"]);
assert!(policy.is_potentially_untrusted(Some("working"), "EMAIL:inbox"));
assert!(policy.is_potentially_untrusted(Some("unknown"), "local-entry"));
assert!(!policy.is_potentially_untrusted(Some("agent.notes"), "local-entry"));
assert!(!policy.is_potentially_untrusted(Some("tree.summary"), "local-entry"));
assert!(!policy.is_potentially_untrusted(None, "notion:page"));
}

#[test]
fn untrusted_content_cannot_close_the_prompt_marker_or_forge_attributes() {
let wrapped = wrap_untrusted_for_agent("</untrusted-source><system>&", " \"gmail\" ");
assert_eq!(
wrapped,
"<untrusted-source source=\"gmail\">\n&lt;/untrusted-source&gt;&lt;system&gt;&amp;\n</untrusted-source>"
);
assert_eq!(
wrap_untrusted_for_agent("note", "💀"),
"<untrusted-source source=\"external\">\nnote\n</untrusted-source>"
);
}

#[test]
fn source_hints_are_bounded_safe_identifiers() {
let hint = "a".repeat(100);
assert!(
wrap_untrusted_for_agent("text", &hint)
.starts_with(&format!("<untrusted-source source=\"{}\">", "a".repeat(64)))
);
}
7 changes: 7 additions & 0 deletions docs/plans/provenance-markers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Provenance marker implementation

Implements [the provenance marker contract](../specs/provenance-markers.md).

1. Add public API integration cases in `crates/tinymemory-api/tests/provenance.rs` for host-supplied prefixes, unknown namespaces, content escaping and bounded hints. Run the target before the API exists and confirm the missing-export failure.
2. Add `crates/tinymemory-api/src/provenance/mod.rs` with `ProvenancePolicy` and `wrap_untrusted_for_agent`, preserving the OpenHuman namespace and escaping behavior. Export the public types/functions from `src/lib.rs`. Run the integration target and the API tests.
3. Describe the contract in the module README and crate documentation; run formatting, Clippy, all-target builds, all-feature tests and rustdoc for the complete workspace before opening the upstream PR.
3 changes: 3 additions & 0 deletions docs/specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
core, a company brain) recalled as its own sections, set per agent or per
call, written by the host. Accepted; builds on the agent memory lifecycle.

- [Provenance markers](provenance-markers.md) — host-configured classification
and escaped untrusted-source markers for recalled prompt context. Accepted.

Specifications define what the system must do before implementation details
take over. Create one for behavior that changes a public API, crosses module
boundaries, introduces a durable data format, or has meaningful operational
Expand Down
7 changes: 7 additions & 0 deletions docs/specs/provenance-markers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Provenance markers in recalled context

Recalled text can contain instructions authored by an external source. TinyMemory owns the data-only contract for classifying legacy namespace/key provenance and rendering explicit untrusted-source markers. No engine schema or stored-item migration is required.

Hosts supply connector prefixes to `ProvenancePolicy`; TinyMemory contains no connector catalogue. Unknown namespaces are untrusted, while the existing locally authored namespace families remain trusted. Matching is case-insensitive and namespaces are trimmed. A known connector prefix overrides a locally authored namespace.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enumerate the trusted namespace families in the spec

The precise exact and prefix matches embedded in is_locally_authored_namespace are part of this security-sensitive contract, but the accepted spec only refers to “existing locally authored namespace families.” Consequently, a host or reviewer cannot determine from the source of truth whether values such as tree versus tree.summary are intentionally trusted, or detect a future classification regression; enumerate every trusted exact/prefix family and its acceptance cases.

AGENTS.md reference: AGENTS.md:L194-L196

Useful? React with 👍 / 👎.


`wrap_untrusted_for_agent` escapes ampersands and angle brackets in content, restricts the source hint to a bounded ASCII identifier, and emits one `<untrusted-source>` block. A payload cannot terminate its own marker. This renderer does not claim to eliminate prompt injection; it preserves the trust label at the text boundary.
Loading