diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 79f3abd..5002d8d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vta-agent-memory", "description": "Durable agent memory stored in your own Verifiable Trust Agent, not in the tool. Save and recall facts across sessions, scoped to a VTA trust context you control and can revoke.", - "version": "0.6.1", + "version": "0.7.0", "keywords": [ "memory", "vta", diff --git a/CLAUDE.md b/CLAUDE.md index ac04ad0..b5c0bb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ src/ enrol.rs two-phase enrolment (init/connect) — the primary path pnm.rs resolving which VTA, and where its pnm session actually lives lazy.rs connect on first use, so the server exists when the VTA doesn't - main.rs CLI: serve | setup | recall | list | forget | doctor + main.rs CLI: serve | setup | recall | list | forget | export | import | doctor setup.rs the online provisioning flow config.rs ~/.config/vta-agent-memory/config.json (0600) store.rs typed ops over vta/memory/* — the only VTA-facing code @@ -32,10 +32,12 @@ src/ authorship.rs signing records on save, verifying author + location on read provenance.rs basis / confidence / evidence: vocabulary, checks, rank weight lifecycle.rs status, validUntil/reviewAfter, history keys, standing + bundle.rs the signed export/import bundle (docs/memory-bundle.md) server.rs the nine MCP tools tests/ common/ the in-process fake VTA + signers, shared memory_roundtrip.rs Store end-to-end against an in-process fake VTA + bundle.rs export -> import, and tampered bundles refused eval.rs, eval/scenarios/ memory quality evaluation — scored scenarios mcp_starts_without_a_vta.rs the real binary, over stdio, with no VTA hook_contract.rs the SessionStart hook's contract @@ -183,6 +185,16 @@ has to mean forget. A status change is a new version signed by whoever made it; only `retract` is allowed on a refused record, because anything else would re-sign content nobody vouches for. +**An import keeps the original author, verifiably.** A record's signature names +the context it was written in, so an imported record is signed by the importer +for where it now is and carries the original verbatim in `importedFrom`; +`authorship::verify` checks both proofs, the key, and that the content is the +same, then attributes the memory to the original author. Do not "simplify" this +by relaxing the location check for imports, or by re-signing as the importer — +the first lets any record be relocated, the second loses who wrote it. Any +later save drops `importedFrom` (`Store::save_versioned`); only `Store::import` +writes it. Opening a bundle is all or nothing. + ## The retrieval constraint `vta/memory/list/0.2` pages a context in ascending key order and can narrow by diff --git a/Cargo.lock b/Cargo.lock index a5353fc..71d8673 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5437,7 +5437,7 @@ checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" [[package]] name = "vta-agent-memory" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 9a64098..5ec55b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vta-agent-memory" -version = "0.6.1" +version = "0.7.0" edition = "2024" rust-version = "1.95.0" description = "Agentic memory for Claude Code, stored in a Verifiable Trust Agent" diff --git a/README.md b/README.md index 4341edd..9b08e98 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ included in its encrypted backups. Revoking this machine's access is one A Claude Code plugin with: -- **Six MCP tools** — `memory_recall`, `memory_get`, `memory_save`, - `memory_forget`, `memory_list`, `memory_context`. +- **Nine MCP tools** — `memory_recall`, `memory_get`, `memory_save`, + `memory_retract`, `memory_dispute`, `memory_deprecate`, `memory_forget`, + `memory_list`, `memory_context`. - **A skill** (`agent-memory`) that says *when* to save and recall, what is worth keeping, and what must never go in. This is most of the value; the tools are the easy part. @@ -266,6 +267,15 @@ reason) or `retracted` (a tombstone that never recalls) — plus `validUntil` the "this was wrong" path; `forget` is still the hard delete, and takes the history with it. +**Memories can leave the VTA without losing who wrote them.** `export` writes a +signed bundle of a context's memories — optionally narrowed by `--type`, +`--basis` or `--status` — with each record's own author signature intact and the +exporter's signature over the whole. `import` checks both and refuses a bundle +that fails either, whole. An imported memory is signed by the importer for where +it now is and carries the original, so it is still attributed to whoever first +wrote it. The format is documented in [`docs/memory-bundle.md`](docs/memory-bundle.md) +for readers that are not this crate. + **Memory is not application state.** "Forget everything" has to stay a safe thing for a user to ask, which it stops being the moment account state lives here. The VTA's `vta/app-state/*/1.0` family — versioned, namespaced, with a change feed — @@ -282,6 +292,8 @@ vta-agent-memory serve # MCP over stdio (the default) vta-agent-memory recall [--query Q] [--type T] [--limit N] [--full] [--format text|json] vta-agent-memory list [--type T] vta-agent-memory forget +vta-agent-memory export [--out FILE] [--type T] [--basis B] [--status S] +vta-agent-memory import FILE|- [--replace] [--dry-run] [--type T] [--basis B] [--status S] vta-agent-memory doctor ``` diff --git a/docs/memory-bundle.md b/docs/memory-bundle.md new file mode 100644 index 0000000..ce8de62 --- /dev/null +++ b/docs/memory-bundle.md @@ -0,0 +1,162 @@ +# Memory bundle format + +A **memory bundle** is a signed JSON file holding some or all of the memories in +one trust context. `vta-agent-memory export` writes one, and +`vta-agent-memory import` reads one. This page describes the format so that +software other than this crate can read and check a bundle. You don't need a VTA +or this crate to do that. + +## Example + +```json +{ + "type": "VtaAgentMemoryBundle", + "bundleVersion": 1, + "exportedBy": "did:key:z6Mk…", + "exportedAt": "2026-11-11T09:30:00+00:00", + "sourceContext": "home", + "filter": { "basis": ["declared", "verified"] }, + "records": [ + { + "key": "feedback/tabs", + "version": 7, + "record": { + "v": 2, + "name": "Tabs", + "type": "feedback", + "description": "indent with tabs", + "body": "…", + "links": [], + "updatedAt": "2026-10-02T08:00:00+00:00", + "basis": "declared", + "confidence": 0.9, + "reviewAfter": "2030-01-01T00:00:00+00:00", + "author": "did:key:z6Mk…", + "authorKind": "operator", + "contextId": "home", + "key": "feedback/tabs", + "proof": { "type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", "…": "…" } + } + } + ], + "proof": { "type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", "…": "…" } +} +``` + +## Top-level fields + +| field | meaning | +|---|---| +| `type` | Always `VtaAgentMemoryBundle`. | +| `bundleVersion` | `1`. A reader must refuse a version it doesn't know. | +| `exportedBy` | The DID that signed the bundle. Must be the signer of `proof`. | +| `exportedAt` | When the bundle was made (RFC 3339). | +| `sourceContext` | The trust context the memories came from. Every record's own `contextId` must equal it. | +| `filter` | What the export was narrowed to: `types`, `basis` and/or `status` lists. `{}` means everything that could be exported. A non-empty filter tells the reader the bundle is a subset. | +| `records` | One entry per memory: `key` (`/`), `version` (the source VTA's version number, for information only) and `record`. | +| `proof` | The exporter's Data Integrity proof over everything above it. | + +## The record + +`record` is the memory **exactly as it was stored**, including its author's own +proof. Exporting never re-signs a record. Its fields are: + +- **Content**: `name`, `type` (`user`, `feedback`, `project` or `reference`), + `description` (one line), `body`, `links` and `updatedAt`. +- **Provenance**: `basis` (`declared`, `observed`, `verified` or `inferred`), + `confidence` (0 to 1, the author's own estimate; never compare it across + authors) and `evidence` (citations, each with a `kind` of `credential`, + `memory` or `record`). This is the same vocabulary as Data Rooms records. +- **Lifecycle**: `status` (absent means `active`; otherwise `deprecated`, + `disputed` or `retracted`), `statusReason`, `validUntil` and `reviewAfter`. + The last two are RFC 3339 dates. +- **Authorship**: `v` (`2` for signed records), `author`, `authorKind` + (`agent` or `operator`), `contextId`, `key` and `proof`. +- **`supersedes`**: where the version this one replaced is kept in the source + context's history. History itself is not exported. +- **`importedFrom`**: present only if the record was itself imported into the + source context. See below. + +A reader should ignore fields it doesn't know about. + +## Checking a bundle + +A bundle is accepted only if all of the following hold. If any check fails, +reject the whole bundle. `import` writes nothing from a bundle that fails a +check. + +1. `type` and `bundleVersion` are as above. +2. The bundle's `proof` verifies over the bundle with `proofPurpose` + `assertionMethod`, and its signer is `exportedBy`. This check catches a + record that was added, removed, edited or moved between bundles. +3. For each record: + - Its `proof` verifies over the record as it appears in the bundle, and + `author` is the signer. + - `contextId` equals `sourceContext`, and `key` equals the entry's `key`. + - The key is `/` and matches the record's own `type` and + slugified `name`. + - Unsigned records are not accepted. +4. No key appears twice. + +Proofs are W3C Data Integrity proofs using `eddsa-jcs-2022` (or +`ecdsa-jcs-2019`), canonicalised with JCS (RFC 8785). That means a bundle can be +re-indented or have its keys reordered without breaking any signature. This +crate resolves only `did:key` verification methods, so checking a bundle needs +no network access. + +Steps 2 and 3 do different jobs. The record proofs say who wrote each memory; +the bundle proof says which memories belong together. Each check on its own +leaves a gap: a forger can re-sign a bundle around edited records, or present +genuine records in a bundle with some of them taken out. + +## Imported records + +A record's signature names the context it was written in, so an imported record +cannot keep that signature as its own: in the new context it would read as +having been moved there. `import` therefore writes a new record signed by the +importer for its new location, and keeps the original, verbatim, in +`importedFrom`. To accept a record that carries `importedFrom`, a reader checks +that: + +- The outer record's proof holds for where it was found. The outer record is + the importer's. +- `importedFrom` is a signed record whose `author` is its signer, whose `key` + matches, and which does not itself contain an `importedFrom`. +- The content of the two records is the same. Content here is everything except + `v`, `author`, `authorKind`, `contextId`, `key`, `proof`, `supersedes` and + `importedFrom`. + +If all of that holds, the memory is attributed to the **original** author, and +the importer and the original context are shown alongside. An importer can't +change what a memory says without breaking that attribution. + +Importing a memory that was itself imported carries the first original rather +than nesting another layer. The original author stays the author however many +times a memory moves. Saving or changing the status of an imported memory drops +`importedFrom`: from then on the person who saved it is the author, and the +imported version is kept in history. + +## What is left out of a bundle + +- **Refused records**, whose signature doesn't hold, and **unattributed + records**, written before records were signed. Neither has an author a + receiver could check. `export` lists them. Saving an unattributed memory again + signs it. +- **History.** A bundle holds the current version of each memory. + +## Commands + +``` +vta-agent-memory export [--out FILE] [--type T]… [--basis B]… [--status S]… +vta-agent-memory import FILE|- [--replace] [--dry-run] [--type T]… [--basis B]… [--status S]… +``` + +Each filter option narrows the set. You can repeat an option or give it a +comma-separated list. `unstated` selects records with no `basis`. `--out` +writes the file readable by its owner only. A bundle holds your memories, so +treat it like any other private file. + +`import` checks the whole bundle before it connects to a VTA. Without +`--replace`, a memory that already exists under the same key is left alone. +With `--replace`, the import becomes a new version of that memory and the +replaced version goes to history. diff --git a/skills/agent-memory/SKILL.md b/skills/agent-memory/SKILL.md index b995e05..36dae6d 100644 --- a/skills/agent-memory/SKILL.md +++ b/skills/agent-memory/SKILL.md @@ -84,7 +84,11 @@ Every memory `memory_recall`, `memory_get` and `memory_list` return carries an - `"status": "verified"` — signed by `did`, and the signature holds for this memory where it was found. `kind` is `agent` or `operator`; `thisAgent: true` - means this machine's own identity wrote it. + means this machine's own identity wrote it. With `importedFrom` and + `importedBy`, the memory was carried in from another context by `import`: + `did` is still whoever originally wrote it there, checked against their own + signature. It is a snapshot from that context, so it may be older than it + looks. - `"status": "unattributed"` — written before memories were signed. Readable, but nothing vouches for who wrote it; give it less weight than a verified one when they disagree. diff --git a/src/authorship.rs b/src/authorship.rs index 090340a..d37cd00 100644 --- a/src/authorship.rs +++ b/src/authorship.rs @@ -32,6 +32,24 @@ //! (`v` ≥ 2) but carries no proof is refused, so stripping a signature does not //! downgrade a record to merely unattributed. //! +//! # Imported records (#36) +//! +//! A record carried to another context by `import` cannot keep its original +//! signature as its own: that signature names the context it was exported +//! from, which is exactly the relocation rule 3 refuses. Re-signing it as the +//! importer would keep it readable but lose who wrote it. So the importer signs +//! a record for where it now is, and carries the original — verbatim, with the +//! original author's proof — in `importedFrom`. A reader checks both +//! signatures, checks that the original was written under the same key, and +//! checks that its content (everything but the authorship, location and +//! history fields) is the content of the record found. The memory is then +//! attributed to its **original** author, and says who imported it from where. +//! +//! The importer cannot change the content without failing that comparison, and +//! cannot invent an original without the original author's key. What it can +//! do is import a memory the original author has since retracted — the bundle +//! is a snapshot — which is why the imported record says when and from where. +//! //! # Verification does no I/O //! //! The resolver is `did:key`-only ([`TrustTaskVmResolver::did_key_only`]): the @@ -124,6 +142,11 @@ impl Signer { self.key.holder_did() } + /// The key itself, for signing something other than a record (a bundle). + pub(crate) fn holder_key(&self) -> &HolderKey { + &self.key + } + /// The kind of author this signer writes as. pub fn kind(&self) -> AuthorKind { self.kind @@ -169,13 +192,35 @@ pub enum Authorship { /// Whether the author is the identity this process signs as — "I wrote /// this" rather than "another writer on this context did". by_this_agent: bool, + /// Set when the record was carried here by `import`: `author` is then + /// the original author, and this says who imported it from where. + imported: Option, }, /// Written before records were signed, or by something that does not sign. /// Readable, but nothing vouches for who wrote it. Unattributed, } +/// Who carried an imported record into this context, and from where. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Imported { + /// The DID that signed the record here — the importer. + pub by: String, + /// The context the original author wrote it in. + pub from_context: String, +} + impl Authorship { + /// A record whose signature holds and that was not imported. + pub fn verified(author: impl Into, kind: Option, mine: bool) -> Self { + Authorship::Verified { + author: author.into(), + kind, + by_this_agent: mine, + imported: None, + } + } + /// The `author` block recall and get return. pub fn to_json(&self) -> Value { match self { @@ -183,12 +228,20 @@ impl Authorship { author, kind, by_this_agent, - } => serde_json::json!({ - "status": "verified", - "did": author, - "kind": kind.map(|k| k.as_str()).unwrap_or("unknown"), - "thisAgent": by_this_agent, - }), + imported, + } => { + let mut v = serde_json::json!({ + "status": "verified", + "did": author, + "kind": kind.map(|k| k.as_str()).unwrap_or("unknown"), + "thisAgent": by_this_agent, + }); + if let Some(i) = imported { + v["importedBy"] = Value::String(i.by.clone()); + v["importedFrom"] = Value::String(i.from_context.clone()); + } + v + } Authorship::Unattributed => serde_json::json!({ "status": "unattributed" }), } } @@ -198,14 +251,31 @@ impl Authorship { pub fn note(&self) -> Option { match self { Authorship::Verified { - by_this_agent: true, - .. - } => None, - Authorship::Verified { author, kind, .. } => Some(format!( - "written by {} {}", - kind.map(|k| k.as_str()).unwrap_or("writer"), - abbreviate_did(author) - )), + author, + kind, + by_this_agent, + imported, + } => { + let written = (!by_this_agent).then(|| { + format!( + "written by {} {}", + kind.map(|k| k.as_str()).unwrap_or("writer"), + abbreviate_did(author) + ) + }); + let carried = imported.as_ref().map(|i| { + format!( + "imported from context `{}`", + crate::fence::Fence::sanitize(&clip(&i.from_context)) + ) + }); + match (written, carried) { + (None, None) => None, + (Some(w), None) => Some(w), + (None, Some(c)) => Some(c), + (Some(w), Some(c)) => Some(format!("{w}, {c}")), + } + } Authorship::Unattributed => Some("unattributed".to_string()), } } @@ -257,11 +327,48 @@ pub async fn verify( return Ok(Authorship::Unattributed); } + let (signer, kind) = check_signature(&raw).await?; + let field = |name: &str| obj.get(name).and_then(Value::as_str); + let (signed_context, signed_key) = (field("contextId"), field("key")); + if signed_context != Some(context_id) || signed_key != Some(key) { + return Err(format!( + "it was signed for `{}` in context `{}`, not where it was found — it was copied or \ + moved here", + clip(signed_key.unwrap_or("")), + clip(signed_context.unwrap_or("")), + )); + } + + let Some(origin) = obj.get("importedFrom") else { + return Ok(Authorship::Verified { + by_this_agent: own_did == Some(signer.as_str()), + kind, + author: signer, + imported: None, + }); + }; + let (author, kind, from_context) = check_origin(origin, &raw, key) + .await + .map_err(|why| format!("it was imported by {}, but {why}", clip(&signer)))?; + Ok(Authorship::Verified { + by_this_agent: own_did == Some(author.as_str()), + kind, + author, + imported: Some(Imported { + by: signer, + from_context, + }), + }) +} + +/// Check a signed object's proof and that its `author` is who signed it: +/// the signer's DID and the kind it stated. +async fn check_signature(raw: &Value) -> Result<(String, Option), String> { // Verified over the value exactly as stored, not a re-serialisation of the // decoded struct: a field this version does not know is still signed, and // dropping it would fail a record a newer writer made correctly. let signer = verify_proof_set( - &raw, + raw, ProofPurpose::AssertionMethod, &TrustTaskVmResolver::did_key_only(), ) @@ -271,7 +378,7 @@ pub async fn verify( })? .into_signer(); - let field = |name: &str| obj.get(name).and_then(Value::as_str); + let field = |name: &str| raw.get(name).and_then(Value::as_str); match field("author") { Some(author) if author == signer => {} Some(author) => { @@ -288,21 +395,68 @@ pub async fn verify( )); } } - let (signed_context, signed_key) = (field("contextId"), field("key")); - if signed_context != Some(context_id) || signed_key != Some(key) { + Ok((signer, field("authorKind").and_then(AuthorKind::parse))) +} + +/// Check the original an imported record carries: signed by its own author, +/// written under the same key, and with the same content as the record found. +/// The original author's DID, kind, and the context the original names. +async fn check_origin( + origin: &Value, + found: &Value, + key: &str, +) -> Result<(String, Option, String), String> { + let Some(obj) = origin.as_object() else { + return Err("the original it carries is not a record".to_string()); + }; + if !obj.contains_key("proof") { + return Err("the original it carries is not signed".to_string()); + } + if obj.contains_key("importedFrom") { + // `import` flattens: a re-imported record carries the first original. + return Err("the original it carries is itself an import".to_string()); + } + let (author, kind) = check_signature(origin) + .await + .map_err(|why| format!("the original it carries is refused: {why}"))?; + let field = |name: &str| obj.get(name).and_then(Value::as_str); + if field("key") != Some(key) { return Err(format!( - "it was signed for `{}` in context `{}`, not where it was found — it was copied or \ - moved here", - clip(signed_key.unwrap_or("")), - clip(signed_context.unwrap_or("")), + "the original it carries was written as `{}`, not `{}`", + clip(field("key").unwrap_or("")), + clip(key) )); } + let Some(from_context) = field("contextId") else { + return Err("the original it carries names no context".to_string()); + }; + if content(origin)? != content(found)? { + return Err( + "its content differs from the original it carries — it was changed on import" + .to_string(), + ); + } + Ok((author, kind, from_context.to_string())) +} - Ok(Authorship::Verified { - by_this_agent: own_did == Some(signer.as_str()), - kind: field("authorKind").and_then(AuthorKind::parse), - author: signer, - }) +/// A record's content: everything but who signed it, where, what it replaced, +/// and the import that brought it — the fields that legitimately differ +/// between an original and its import. +/// +/// Compared through [`MemoryRecord`], so a field this version does not know is +/// ignored on both sides rather than failing a record a newer writer made. +fn content(record: &Value) -> Result { + let mut r: MemoryRecord = serde_json::from_value(record.clone()) + .map_err(|e| format!("a record it carries is not a memory record ({e})"))?; + r.v = 0; + r.author = None; + r.author_kind = None; + r.context_id = None; + r.key = None; + r.proof = None; + r.supersedes = None; + r.imported_from = None; + serde_json::to_value(&r).map_err(|e| e.to_string()) } /// A string taken from a refused record, cut short for its refusal reason. diff --git a/src/bundle.rs b/src/bundle.rs new file mode 100644 index 0000000..5376e15 --- /dev/null +++ b/src/bundle.rs @@ -0,0 +1,353 @@ +//! A signed, verifiable bundle of memories (#36): what `export` writes and +//! `import` reads. +//! +//! Memory is portable across AI providers because it lives in the user's VTA; +//! a bundle makes it portable *out of* one — to another VTA, another context, +//! an archive, or a system that is not a VTA at all. The format is documented +//! for those readers in `docs/memory-bundle.md`; this module is its one +//! implementation. +//! +//! # Two layers of signature +//! +//! - **Each record keeps its author's own proof**, verbatim, as it was stored. +//! Exporting does not re-author anything: a receiver can check every memory +//! against the DID that wrote it, independently of who exported it. +//! - **The bundle carries the exporter's proof** over all of it — the record +//! list, the source context and the filter. That is what makes a bundle a +//! unit: a record dropped from it, added to it, or moved between bundles +//! fails the bundle's proof even though each record's own proof still holds. +//! +//! Opening a bundle checks both, and is all or nothing: a bundle with one bad +//! record is refused whole, because a bundle somebody tampered with is not one +//! to take any part of on trust. +//! +//! # What is not exported +//! +//! Records that are refused (their signature does not hold) and records that +//! are unattributed (written before signing existed) — neither has an author +//! a receiver could check. The export report lists them; re-saving an +//! unattributed memory signs it. History is not exported either: a bundle is +//! the current version of each memory, which is what a receiver can use. + +use serde_json::{Map, Value, json}; +use vta_sdk::trust_task_proof::{ProofPurpose, TrustTaskVmResolver, verify_proof_set}; +use vta_sdk::trust_task_sign::sign_object_for_assertion; + +use crate::authorship::{Authorship, Signer, verify}; +use crate::lifecycle::Status; +use crate::record::{MemoryKey, MemoryRecord, MemoryType}; + +/// The bundle's `type`. +pub const BUNDLE_TYPE: &str = "VtaAgentMemoryBundle"; +/// The bundle format version this crate writes and reads. +pub const BUNDLE_VERSION: u64 = 1; +/// Largest bundle `import` reads, in bytes. A context's memories are bounded +/// notes; a bundle far past this is not one this crate wrote. +pub const MAX_BUNDLE_BYTES: usize = 64 * 1024 * 1024; + +/// Which memories to carry: each list narrows, an empty list does not. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Filter { + /// Memory types. + pub types: Vec, + /// Bases — `declared`, `observed`, `verified`, `inferred`, or `unstated` + /// for a record that names none. + pub basis: Vec, + /// Statuses — `active`, `deprecated`, `disputed`, `retracted`. + pub status: Vec, +} + +impl Filter { + /// Build a filter from the words a person typed, naming the first one that + /// is not a type, basis or status. + pub fn parse(types: &[String], basis: &[String], status: &[String]) -> Result { + let split = |items: &[String]| -> Vec { + items + .iter() + .flat_map(|s| s.split(',')) + .map(|s| s.trim().to_ascii_lowercase()) + .filter(|s| !s.is_empty()) + .collect() + }; + let types = split(types) + .into_iter() + .map(|t| { + MemoryType::parse(&t).ok_or_else(|| { + format!("unknown type `{t}`; expected user, feedback, project or reference") + }) + }) + .collect::>()?; + let basis = split(basis) + .into_iter() + .map(|b| match b.as_str() { + "declared" | "observed" | "verified" | "inferred" | "unstated" => Ok(b), + _ => Err(format!( + "unknown basis `{b}`; expected declared, observed, verified, inferred or \ + unstated" + )), + }) + .collect::>()?; + let status = split(status) + .into_iter() + .map(|s| { + Status::parse(Some(&s)).ok_or_else(|| { + format!( + "unknown status `{s}`; expected active, deprecated, disputed or retracted" + ) + }) + }) + .collect::>()?; + Ok(Self { + types, + basis, + status, + }) + } + + /// Whether `record` is carried. + pub fn matches(&self, record: &MemoryRecord) -> bool { + let basis = record.basis.as_deref().unwrap_or("unstated"); + (self.types.is_empty() || self.types.contains(&record.kind)) + && (self.basis.is_empty() || self.basis.iter().any(|b| b == basis)) + && (self.status.is_empty() + || Status::parse(record.status.as_deref()) + .is_some_and(|s| self.status.contains(&s))) + } + + /// The `filter` member of a bundle: what it was narrowed to, so a receiver + /// knows it is a subset. + pub fn to_json(&self) -> Value { + let mut v = Map::new(); + if !self.types.is_empty() { + v.insert( + "types".into(), + json!(self.types.iter().map(|t| t.as_str()).collect::>()), + ); + } + if !self.basis.is_empty() { + v.insert("basis".into(), json!(self.basis)); + } + if !self.status.is_empty() { + v.insert( + "status".into(), + json!(self.status.iter().map(|s| s.as_str()).collect::>()), + ); + } + Value::Object(v) + } +} + +/// One memory as it goes into a bundle: its key, the version it was stored at, +/// and the stored record, verbatim. +#[derive(Debug, Clone)] +pub struct Item { + pub key: MemoryKey, + pub version: u64, + pub record: Value, +} + +/// Seal `items` from `source_context` into a bundle signed by `signer`. +pub async fn seal( + signer: &Signer, + source_context: &str, + filter: &Filter, + items: &[Item], +) -> anyhow::Result { + let mut bundle = json!({ + "type": BUNDLE_TYPE, + "bundleVersion": BUNDLE_VERSION, + "exportedBy": signer.did(), + "exportedAt": chrono::Utc::now().to_rfc3339(), + "sourceContext": source_context, + "filter": filter.to_json(), + "records": items + .iter() + .map(|i| json!({ "key": i.key.to_string(), "version": i.version, "record": i.record })) + .collect::>(), + }); + sign_object_for_assertion(&mut bundle, signer.holder_key()) + .await + .map_err(|e| anyhow::anyhow!("signing the bundle: {e}"))?; + Ok(bundle) +} + +/// A bundle whose signatures all hold. +#[derive(Debug)] +pub struct Opened { + /// The DID that signed the bundle. + pub exported_by: String, + pub exported_at: String, + /// The context the memories were exported from. + pub source_context: String, + /// The filter the exporter applied. + pub filter: Value, + /// Each memory, checked against its own author. + pub memories: Vec, +} + +/// One memory from an opened bundle. +#[derive(Debug)] +pub struct Memory { + pub key: MemoryKey, + /// The record as stored in the source context. + pub record: MemoryRecord, + /// Who wrote it — the original author, also for a record that was itself + /// imported into the source context. + pub authorship: Authorship, + /// The original as its author signed it — what an import carries as + /// `importedFrom`. For a record imported into the source context, that + /// import's own original: imports flatten rather than nest. + pub origin: Value, +} + +/// Check a bundle: its type and version, the exporter's proof, and every +/// record's own proof at the key and context it names. `Err` with why, for the +/// whole bundle — see the module docs for why it is all or nothing. +pub async fn open(raw: &str) -> Result { + if raw.len() > MAX_BUNDLE_BYTES { + return Err(format!( + "the bundle is {} bytes; the limit is {MAX_BUNDLE_BYTES}", + raw.len() + )); + } + let bundle: Value = + serde_json::from_str(raw).map_err(|e| format!("the bundle is not JSON ({e})"))?; + let field = |name: &str| bundle.get(name).and_then(Value::as_str); + if field("type") != Some(BUNDLE_TYPE) { + return Err(format!( + "it is not a memory bundle (`type` is not `{BUNDLE_TYPE}`)" + )); + } + match bundle.get("bundleVersion").and_then(Value::as_u64) { + Some(BUNDLE_VERSION) => {} + Some(v) => { + return Err(format!( + "it is bundle format {v}; this version reads format {BUNDLE_VERSION}" + )); + } + None => return Err("it names no `bundleVersion`".to_string()), + } + + if bundle.get("proof").is_none() { + return Err("it is not signed, so nothing says who exported it".to_string()); + } + let signer = verify_proof_set( + &bundle, + ProofPurpose::AssertionMethod, + &TrustTaskVmResolver::did_key_only(), + ) + .await + .map_err(|e| { + format!("its signature does not verify ({e}) — it was altered after it was exported") + })? + .into_signer(); + let exported_by = field("exportedBy").unwrap_or_default(); + if exported_by != signer { + return Err(format!( + "it claims to be exported by `{exported_by}` but was signed by `{signer}`" + )); + } + let source_context = field("sourceContext") + .ok_or("it names no `sourceContext`")? + .to_string(); + let records = bundle + .get("records") + .and_then(Value::as_array) + .ok_or("it has no `records` list")?; + + let mut memories = Vec::with_capacity(records.len()); + let mut seen = std::collections::HashSet::new(); + for (i, item) in records.iter().enumerate() { + let memory = open_item(item, &source_context) + .await + .map_err(|why| format!("record {i} is refused: {why}"))?; + if !seen.insert(memory.key.to_string()) { + return Err(format!("it holds `{}` twice", memory.key)); + } + memories.push(memory); + } + + Ok(Opened { + exported_by: signer, + exported_at: field("exportedAt").unwrap_or_default().to_string(), + source_context, + filter: bundle.get("filter").cloned().unwrap_or(Value::Null), + memories, + }) +} + +async fn open_item(item: &Value, source_context: &str) -> Result { + let raw_key = item + .get("key") + .and_then(Value::as_str) + .ok_or("it names no `key`")?; + let key = MemoryKey::parse(raw_key).map_err(|e| e.to_string())?; + let record_value = item.get("record").ok_or("it has no `record`")?; + let stored = serde_json::to_string(record_value).map_err(|e| e.to_string())?; + + let authorship = verify(&stored, source_context, raw_key, None) + .await + .map_err(|why| format!("`{key}`: {why}"))?; + if authorship == Authorship::Unattributed { + return Err(format!( + "`{key}` is not signed, so nothing says who wrote it" + )); + } + let record: MemoryRecord = serde_json::from_value(record_value.clone()) + .map_err(|e| format!("`{key}` is not a memory record ({e})"))?; + // Where an import will write it: the key its own name and type address. + if MemoryKey::new(record.kind, &record.name).ok().as_ref() != Some(&key) { + return Err(format!( + "`{key}` holds a record named `{}`, which is stored elsewhere", + record.name + )); + } + record + .check_limits() + .map_err(|why| format!("`{key}`: {why}"))?; + let origin = record + .imported_from + .clone() + .unwrap_or_else(|| record_value.clone()); + Ok(Memory { + key, + record, + authorship, + origin, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_filter_parses_commas_and_repeats_and_names_a_bad_word() { + let f = Filter::parse( + &["user,feedback".into()], + &["declared".into(), "unstated".into()], + &["active".into()], + ) + .unwrap(); + assert_eq!(f.types, vec![MemoryType::User, MemoryType::Feedback]); + assert_eq!(f.basis, vec!["declared", "unstated"]); + assert_eq!(f.status, vec![Status::Active]); + + let err = Filter::parse(&[], &["guessed".into()], &[]).unwrap_err(); + assert!(err.contains("guessed"), "{err}"); + } + + #[test] + fn a_filter_narrows_on_every_list_it_has() { + let mut r = MemoryRecord::new(MemoryType::User, "n", "d", "b", vec![]); + assert!(Filter::default().matches(&r)); + let declared = Filter::parse(&[], &["declared".into()], &[]).unwrap(); + assert!(!declared.matches(&r), "no basis is `unstated`"); + r.basis = Some("declared".into()); + assert!(declared.matches(&r)); + let retracted = Filter::parse(&[], &[], &["retracted".into()]).unwrap(); + assert!(!retracted.matches(&r), "no status is active"); + let projects = Filter::parse(&["project".into()], &[], &[]).unwrap(); + assert!(!projects.matches(&r)); + } +} diff --git a/src/lib.rs b/src/lib.rs index aa11be6..9f677ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ //! design decisions live. [`store`] is the only module that talks to a VTA. pub mod authorship; +pub mod bundle; pub mod config; pub mod enrol; pub mod fence; diff --git a/src/main.rs b/src/main.rs index bea3c4e..d5f874a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ use rmcp::ServiceExt; use rmcp::transport::stdio; use vta_agent_memory::authorship::Refused; +use vta_agent_memory::bundle::{self, Filter}; use vta_agent_memory::config::{ALLOW_OPERATOR_LOGIN_ENV, Config, operator_login_allowed_by_env}; use vta_agent_memory::fence::{Fence, Provenance}; use vta_agent_memory::record::{self, MemoryKey, MemoryType}; @@ -152,10 +153,57 @@ enum Command { key: String, }, + /// Write a signed bundle of this context's memories — to carry to another + /// context or VTA, or to keep. Format: `docs/memory-bundle.md`. + Export { + /// Where to write it. Omit for stdout. + #[arg(long, short)] + out: Option, + #[command(flatten)] + filter: FilterArgs, + }, + + /// Verify a bundle and save its memories here, attributed to whoever + /// originally wrote them. A bundle that fails any check is refused whole. + Import { + /// The bundle file, or `-` for stdin. + file: PathBuf, + /// Replace memories already here under the same key (the replaced + /// version is kept in history). Without it they are left alone. + #[arg(long)] + replace: bool, + /// Verify and report what would be imported, writing nothing. + #[arg(long)] + dry_run: bool, + #[command(flatten)] + filter: FilterArgs, + }, + /// Check the configured connection and context access. Doctor, } +/// Which memories `export` or `import` carries. Each option narrows, repeats +/// or takes a comma-separated list, and is ignored when absent. +#[derive(clap::Args, Debug)] +struct FilterArgs { + /// Memory types: user, feedback, project, reference. + #[arg(long = "type", value_name = "TYPE")] + types: Vec, + /// Bases: declared, observed, verified, inferred, unstated. + #[arg(long, value_name = "BASIS")] + basis: Vec, + /// Statuses: active, deprecated, disputed, retracted. + #[arg(long, value_name = "STATUS")] + status: Vec, +} + +impl FilterArgs { + fn parse(&self) -> anyhow::Result { + Filter::parse(&self.types, &self.basis, &self.status).map_err(|e| anyhow::anyhow!(e)) + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] enum OutputFormat { Text, @@ -246,6 +294,13 @@ async fn main() -> anyhow::Result<()> { } => recall(&config_path, query, kind, limit, format, full).await, Command::List { kind } => list(&config_path, kind).await, Command::Forget { key } => forget(&config_path, &key).await, + Command::Export { out, filter } => export(&config_path, out, &filter).await, + Command::Import { + file, + replace, + dry_run, + filter, + } => import(&config_path, &file, replace, dry_run, &filter).await, Command::Doctor => doctor(&config_path).await, } } @@ -394,6 +449,146 @@ async fn forget(config_path: &std::path::Path, key: &str) -> anyhow::Result<()> Ok(()) } +async fn export( + config_path: &std::path::Path, + out: Option, + filter: &FilterArgs, +) -> anyhow::Result<()> { + let filter = filter.parse()?; + let (cfg, store) = open(config_path).await?; + let result = async { + let (items, left_out) = store.exportable(&filter).await?; + let signer = store + .signer() + .ok_or_else(|| anyhow::anyhow!("no signing key to sign the bundle with"))?; + let sealed = bundle::seal(signer, &cfg.context_id, &filter, &items).await?; + Ok::<_, anyhow::Error>((sealed, items.len(), left_out)) + } + .await; + let (sealed, count, left_out) = finish(store, result).await?; + + let text = serde_json::to_string_pretty(&sealed)?; + match &out { + Some(path) => write_private(path, &text)?, + None => println!("{text}"), + } + // The report goes to stderr: stdout may be the bundle. + eprintln!( + "Exported {count} memor{} from context `{}`{}.", + if count == 1 { "y" } else { "ies" }, + cfg.context_id, + out.as_ref() + .map(|p| format!(" to {}", p.display())) + .unwrap_or_default() + ); + if !left_out.is_empty() { + eprintln!("Left out {}:", left_out.len()); + for r in &left_out { + eprintln!(" {} — {}", r.key, r.reason); + } + } + Ok(()) +} + +/// Write `text` to `path` readable by its owner alone: a bundle is the user's +/// memories, which are not for every account on the machine. +fn write_private(path: &std::path::Path, text: &str) -> anyhow::Result<()> { + use std::io::Write; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(path) + .map_err(|e| anyhow::anyhow!("writing {}: {e}", path.display()))?; + file.write_all(text.as_bytes())?; + file.write_all(b"\n")?; + Ok(()) +} + +async fn import( + config_path: &std::path::Path, + file: &std::path::Path, + replace: bool, + dry_run: bool, + filter: &FilterArgs, +) -> anyhow::Result<()> { + let filter = filter.parse()?; + let raw = if file.as_os_str() == "-" { + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + s + } else { + std::fs::read_to_string(file) + .map_err(|e| anyhow::anyhow!("reading {}: {e}", file.display()))? + }; + // Checked before connecting: a bundle that does not verify needs no VTA + // to say so. + let opened = bundle::open(&raw) + .await + .map_err(|why| anyhow::anyhow!("the bundle is refused, and nothing was imported: {why}"))?; + let chosen: Vec<_> = opened + .memories + .iter() + .filter(|m| filter.matches(&m.record)) + .collect(); + println!( + "Bundle from context `{}`, exported by {} at {}: {} memor{}, all signatures valid.", + opened.source_context, + opened.exported_by, + opened.exported_at, + opened.memories.len(), + if opened.memories.len() == 1 { + "y" + } else { + "ies" + }, + ); + if dry_run { + for m in &chosen { + println!(" would import {}", m.key); + } + return Ok(()); + } + + let (cfg, store) = open(config_path).await?; + let result = async { + let mut report = Vec::new(); + for m in &chosen { + let outcome = store.import(m, replace).await?; + report.push((m.key.to_string(), outcome)); + } + Ok::<_, anyhow::Error>(report) + } + .await; + let report = finish(store, result).await?; + + let mut imported = 0; + for (key, outcome) in &report { + match outcome { + vta_agent_memory::store::SaveOutcome::Saved { .. } => { + imported += 1; + println!(" imported {key}"); + } + vta_agent_memory::store::SaveOutcome::Conflict { .. } if replace => { + println!(" skipped {key} — another writer changed it while importing"); + } + vta_agent_memory::store::SaveOutcome::Conflict { .. } => { + println!(" skipped {key} — already here (`--replace` replaces it)"); + } + } + } + println!( + "Imported {imported} of {} into context `{}`.", + chosen.len(), + cfg.context_id + ); + Ok(()) +} + async fn doctor(config_path: &std::path::Path) -> anyhow::Result<()> { let cfg = Config::load(config_path)?; println!("config {}", config_path.display()); @@ -696,6 +891,7 @@ mod tests { author: "did:key:z6MkMine".into(), kind: Some(AuthorKind::Agent), by_this_agent: true, + imported: None, }, ..entry(MemoryType::User, "Mine", "written here", "") }; @@ -704,6 +900,7 @@ mod tests { author: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK".into(), kind: Some(AuthorKind::Operator), by_this_agent: false, + imported: None, }, ..entry(MemoryType::User, "Theirs", "written elsewhere", "") }; diff --git a/src/record.rs b/src/record.rs index 3f0a073..b61464f 100644 --- a/src/record.rs +++ b/src/record.rs @@ -286,6 +286,16 @@ pub struct MemoryRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub supersedes: Option, + // ---- #36: carried in from another context by `import`. + /// The record as its original author signed it in the context it was + /// exported from, verbatim. Present only on a record `import` wrote: the + /// importer signs this record (for where it now is), and a reader checks + /// that the original author's signed content is the same, so authorship + /// survives the move. Cleared by any later save — that writer is the + /// author from then on. See [`crate::authorship`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub imported_from: Option, + // ---- v2: authorship. Set by `authorship::Signer::sign`, never by a caller, // and only believed once `authorship::verify` has checked them against the // proof and the place the record was found. @@ -332,6 +342,7 @@ impl MemoryRecord { valid_until: None, review_after: None, supersedes: None, + imported_from: None, author: None, author_kind: None, context_id: None, @@ -392,6 +403,7 @@ impl MemoryRecord { valid_until: None, review_after: None, supersedes: None, + imported_from: None, author: None, author_kind: None, context_id: None, diff --git a/src/store.rs b/src/store.rs index 6b9014b..bca7604 100644 --- a/src/store.rs +++ b/src/store.rs @@ -71,7 +71,7 @@ use vta_sdk::specs::vta::memory::{ put::v0_3 as put_spec, }; -use crate::authorship::{Refused, Signer, verify}; +use crate::authorship::{Authorship, Refused, Signer, verify}; use crate::lifecycle::{self, Standing, Status}; use crate::record::{Entry, Hit, MemoryKey, MemoryRecord, MemoryType, rank}; @@ -232,6 +232,19 @@ impl Store { &self, record: &MemoryRecord, expected: Option, + ) -> anyhow::Result { + // Whoever saves is the author from now on, so an import's original no + // longer describes this record (and would not match it). + let mut record = record.clone(); + record.imported_from = None; + self.write_versioned(&record, expected).await + } + + /// [`save_versioned`](Self::save_versioned), keeping `importedFrom`. + async fn write_versioned( + &self, + record: &MemoryRecord, + expected: Option, ) -> anyhow::Result { let key = MemoryKey::new(record.kind, &record.name)?; let key_str = key.to_string(); @@ -368,6 +381,60 @@ impl Store { .await } + /// Every memory a bundle can carry that `filter` selects, verbatim as + /// stored, and every one it cannot, with why. Refused and unattributed + /// records cannot be carried: neither has an author a receiver could check. + pub async fn exportable( + &self, + filter: &crate::bundle::Filter, + ) -> anyhow::Result<(Vec, Vec)> { + let mut items = Vec::new(); + let mut left_out = Vec::new(); + for (raw, value, version) in self.raw_list("").await? { + let Ok(key) = MemoryKey::parse(&raw) else { + continue; // history, or another tool's entry + }; + match self.check(key.clone(), &value, version).await { + Err(refused) => left_out.push(refused), + Ok(entry) if entry.authorship == Authorship::Unattributed => { + left_out.push(Refused { + key: raw, + reason: "it is unattributed (written before records were signed); \ + saving it again signs it" + .to_string(), + }) + } + Ok(entry) if !filter.matches(&entry.record) => {} + Ok(_) => items.push(crate::bundle::Item { + key, + version, + record: serde_json::from_str(&value) + .context("a signed record that is not JSON")?, + }), + } + } + Ok((items, left_out)) + } + + /// Write one memory from a bundle, attributed to its original author. + /// + /// The record is signed by this store for where it now is, and carries the + /// original verbatim as `importedFrom`, which a reader checks it against + /// (see [`crate::authorship`]). Without `replace` a memory already under + /// the key is left alone, and the outcome is a [`SaveOutcome::Conflict`]; + /// with it, the import is a new version and the old one goes to history. + pub async fn import( + &self, + memory: &crate::bundle::Memory, + replace: bool, + ) -> anyhow::Result { + let mut record = memory.record.clone(); + // Its history is in the context it came from. + record.supersedes = None; + record.imported_from = Some(memory.origin.clone()); + self.write_versioned(&record, (!replace).then_some(0)).await + } + /// Earlier versions of `key`, newest first, each checked against the key /// it was written to. pub async fn history(&self, key: &MemoryKey) -> anyhow::Result> { diff --git a/tests/bundle.rs b/tests/bundle.rs new file mode 100644 index 0000000..5a44593 --- /dev/null +++ b/tests/bundle.rs @@ -0,0 +1,374 @@ +//! Export and import (#36): a signed bundle carries memories to another +//! context with their provenance, lifecycle and original authorship intact, and +//! a bundle — or an imported record — that was tampered with is refused. +//! +//! Same in-process fake VTA as `memory_roundtrip.rs`, so the records exported +//! and imported are the ones the real `Store` signs and writes. + +mod common; + +use std::sync::Arc; + +use common::*; +use serde_json::Value; +use vta_sdk::client::VtaClient; + +use vta_agent_memory::authorship::{AuthorKind, Authorship, Imported}; +use vta_agent_memory::bundle::{self, Filter}; +use vta_agent_memory::lifecycle::Status; +use vta_agent_memory::provenance::{Basis, Evidence}; +use vta_agent_memory::record::{MemoryKey, MemoryRecord, MemoryType}; +use vta_agent_memory::store::{SaveOutcome, Store}; + +fn record(kind: MemoryType, name: &str, description: &str) -> MemoryRecord { + MemoryRecord::new(kind, name, description, format!("body of {name}"), vec![]) +} + +/// A context holding three memories with provenance and lifecycle, written by +/// two different authors. +async fn source() -> (Arc, Store, Store) { + let vta = Arc::new(FakeMemoryKeyspace::default()); + let agent = store_on(&vta, "home"); + let operator = Store::new(VtaClient::loopback(vta.clone()), "home") + .with_signer(signer(AuthorKind::Operator)); + + let mut tabs = record(MemoryType::Feedback, "Tabs", "indent with tabs").with_provenance( + Basis::Declared, + Some(0.9), + vec![], + ); + tabs.review_after = Some("2030-01-01T00:00:00+00:00".into()); + operator.save(&tabs).await.unwrap(); + + let ci = record(MemoryType::Project, "CI runner", "tests run in CI").with_provenance( + Basis::Verified, + None, + vec![Evidence::Memory { + key: "feedback/tabs".into(), + }], + ); + agent.save(&ci).await.unwrap(); + + agent + .save( + &record(MemoryType::User, "Timezone", "works in UTC+8").with_provenance( + Basis::Inferred, + Some(0.4), + vec![], + ), + ) + .await + .unwrap(); + agent + .set_status( + &MemoryKey::parse("user/timezone").unwrap(), + Status::Disputed, + "moved to London", + None, + ) + .await + .unwrap(); + (vta, agent, operator) +} + +async fn export(store: &Store, filter: &Filter) -> String { + let (items, _) = store.exportable(filter).await.unwrap(); + let sealed = bundle::seal(store.signer().unwrap(), store.context_id(), filter, &items) + .await + .unwrap(); + serde_json::to_string_pretty(&sealed).unwrap() +} + +async fn import_all(store: &Store, raw: &str, replace: bool) -> Vec { + let opened = bundle::open(raw).await.unwrap(); + let mut out = Vec::new(); + for m in &opened.memories { + out.push(store.import(m, replace).await.unwrap()); + } + out +} + +/// Acceptance: export, then import into a fresh context, preserves records, +/// provenance, lifecycle, timestamps and who wrote each one. +#[tokio::test] +async fn export_then_import_into_a_fresh_context_preserves_everything() { + let (vta, agent, operator) = source().await; + let before = agent.list().await.unwrap().entries; + let raw = export(&agent, &Filter::default()).await; + + // Another device, another context, another identity. + let elsewhere = store_on(&vta, "work"); + let outcomes = import_all(&elsewhere, &raw, false).await; + assert!( + outcomes + .iter() + .all(|o| matches!(o, SaveOutcome::Saved { .. })) + ); + + let listing = elsewhere.list().await.unwrap(); + assert!(listing.refused.is_empty(), "{:?}", listing.refused); + assert_eq!(listing.entries.len(), before.len()); + for old in &before { + let new = listing + .entries + .iter() + .find(|e| e.key == old.key) + .expect("every memory arrives"); + let (a, b) = (&old.record, &new.record); + assert_eq!( + (&a.name, &a.description, &a.body, &a.updated_at), + (&b.name, &b.description, &b.body, &b.updated_at) + ); + assert_eq!( + (&a.basis, a.confidence, &a.evidence), + (&b.basis, b.confidence, &b.evidence) + ); + assert_eq!( + (&a.status, &a.status_reason, &a.review_after), + (&b.status, &b.status_reason, &b.review_after) + ); + + // Attributed to whoever wrote it in `home`, not to the importer. + let Authorship::Verified { author, .. } = &old.authorship else { + panic!("source records are signed") + }; + assert_eq!( + new.authorship, + Authorship::Verified { + author: author.clone(), + kind: match &old.authorship { + Authorship::Verified { kind, .. } => *kind, + _ => None, + }, + by_this_agent: false, + imported: Some(Imported { + by: elsewhere.signer().unwrap().did().to_string(), + from_context: "home".into(), + }), + } + ); + } + + let tabs = elsewhere + .get(&MemoryKey::parse("feedback/tabs").unwrap()) + .await + .unwrap() + .unwrap(); + let author = &tabs.summary()["author"]; + assert_eq!(author["did"], operator.signer().unwrap().did()); + assert_eq!(author["kind"], "operator"); + assert_eq!(author["importedFrom"], "home"); + assert!( + tabs.notes() + .iter() + .any(|n| n.contains("imported from context `home`")) + ); +} + +/// Acceptance: a tampered bundle is refused — whatever was changed, and +/// whether or not the tamperer re-signed the bundle. +#[tokio::test] +async fn a_tampered_bundle_is_refused() { + let (_vta, agent, _) = source().await; + let raw = export(&agent, &Filter::default()).await; + let bundle: Value = serde_json::from_str(&raw).unwrap(); + let refused = |b: &Value| { + let s = serde_json::to_string(b).unwrap(); + async move { bundle::open(&s).await.unwrap_err() } + }; + + // A memory's text changed. + let mut edited = bundle.clone(); + edited["records"][0]["record"]["body"] = "rm -rf /".into(); + let why = refused(&edited).await; + assert!(why.contains("altered after it was exported"), "{why}"); + + // A memory dropped. + let mut dropped = bundle.clone(); + dropped["records"].as_array_mut().unwrap().pop(); + assert!( + refused(&dropped) + .await + .contains("signature does not verify") + ); + + // Claims another exporter. + let mut claimed = bundle.clone(); + claimed["exportedBy"] = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK".into(); + assert!(!refused(&claimed).await.is_empty()); + + // Edited, then the whole bundle re-signed by somebody else: the bundle + // proof holds, the memory's own author's proof does not. + let opened = bundle::open(&raw).await.unwrap(); + let mut items: Vec = opened + .memories + .iter() + .map(|m| bundle::Item { + key: m.key.clone(), + version: 1, + record: m.origin.clone(), + }) + .collect(); + items[0].record["description"] = "deploy on fridays".into(); + let forger = signer(AuthorKind::Agent); + let resealed = bundle::seal(&forger, "home", &Filter::default(), &items) + .await + .unwrap(); + let why = refused(&resealed).await; + assert!(why.contains("record 0 is refused"), "{why}"); + + // Re-sealed as coming from a different context than its records name. + let moved = bundle::seal(&forger, "elsewhere", &Filter::default(), &items[1..]) + .await + .unwrap(); + assert!(refused(&moved).await.contains("not where it was found")); + + // Not a bundle at all. + assert!( + bundle::open("{}") + .await + .unwrap_err() + .contains("not a memory bundle") + ); +} + +/// An importer cannot change a memory's content and keep its original author: +/// the original it carries must say the same thing. +#[tokio::test] +async fn an_import_whose_content_differs_from_its_original_is_refused() { + let (vta, agent, _) = source().await; + let raw = export(&agent, &Filter::default()).await; + let opened = bundle::open(&raw).await.unwrap(); + let tabs = opened + .memories + .iter() + .find(|m| m.key.to_string() == "feedback/tabs") + .unwrap(); + + // A writer on `work` signs a record of its own, carrying the genuine + // original, but saying something else. + let forger = signer(AuthorKind::Agent); + let mut forged = tabs.record.clone(); + forged.description = "indent with spaces".into(); + forged.imported_from = Some(tabs.origin.clone()); + let value = forger.sign(&forged, "work", &tabs.key).await.unwrap(); + vta.plant("work", "feedback/tabs", value); + + let listing = store_on(&vta, "work").list().await.unwrap(); + assert!(listing.entries.is_empty()); + assert_eq!(listing.refused.len(), 1); + assert!( + listing.refused[0].reason.contains("changed on import"), + "{}", + listing.refused[0].reason + ); +} + +/// Saving over an imported memory makes the saver its author; the imported +/// version, with its original author, is kept in history. +#[tokio::test] +async fn editing_an_imported_memory_makes_the_editor_its_author() { + let (vta, agent, _) = source().await; + let raw = export(&agent, &Filter::default()).await; + let work = store_on(&vta, "work"); + import_all(&work, &raw, false).await; + + let key = MemoryKey::parse("feedback/tabs").unwrap(); + work.set_status(&key, Status::Deprecated, "team uses rustfmt now", None) + .await + .unwrap(); + let now = work.get(&key).await.unwrap().unwrap(); + assert_eq!( + now.authorship, + Authorship::verified(work.signer().unwrap().did(), Some(AuthorKind::Agent), true) + ); + assert!(now.record.imported_from.is_none()); + let history = work.history(&key).await.unwrap(); + assert!(matches!( + &history[0].authorship, + Authorship::Verified { + imported: Some(_), + .. + } + )); +} + +/// A memory exported from where it was imported keeps its original author, +/// not the first importer: imports flatten, they do not nest. +#[tokio::test] +async fn re_exporting_an_import_keeps_the_original_author() { + let (vta, agent, operator) = source().await; + let work = store_on(&vta, "work"); + import_all(&work, &export(&agent, &Filter::default()).await, false).await; + + let third = store_on(&vta, "archive-box"); + import_all(&third, &export(&work, &Filter::default()).await, false).await; + let tabs = third + .get(&MemoryKey::parse("feedback/tabs").unwrap()) + .await + .unwrap() + .unwrap(); + let Authorship::Verified { + author, imported, .. + } = &tabs.authorship + else { + panic!("verified") + }; + assert_eq!(author, operator.signer().unwrap().did()); + assert_eq!(imported.as_ref().unwrap().from_context, "home"); +} + +/// Without `replace`, a memory already in the target is left alone. +#[tokio::test] +async fn import_does_not_overwrite_unless_asked() { + let (vta, agent, _) = source().await; + let raw = export(&agent, &Filter::default()).await; + let work = store_on(&vta, "work"); + work.save(&record(MemoryType::Feedback, "Tabs", "mine, not theirs")) + .await + .unwrap(); + + let key = MemoryKey::parse("feedback/tabs").unwrap(); + import_all(&work, &raw, false).await; + assert_eq!( + work.get(&key).await.unwrap().unwrap().record.description, + "mine, not theirs" + ); + + import_all(&work, &raw, true).await; + let now = work.get(&key).await.unwrap().unwrap(); + assert_eq!(now.record.description, "indent with tabs"); + assert_eq!( + vta.history_len("work", "feedback/tabs"), + 1, + "the replaced one is kept" + ); +} + +/// A filter carries a subset, and says so in the bundle; unsigned and refused +/// records are left out with why. +#[tokio::test] +async fn a_filtered_export_carries_only_what_it_selects() { + let (vta, agent, _) = source().await; + vta.plant( + "home", + "user/legacy", + r#"{"v":1,"name":"legacy","type":"user","description":"old","body":"","updatedAt":""}"#, + ); + + let filter = Filter::parse(&[], &["declared,verified".into()], &["active".into()]).unwrap(); + let (items, left_out) = agent.exportable(&filter).await.unwrap(); + let mut keys: Vec<_> = items.iter().map(|i| i.key.to_string()).collect(); + keys.sort(); + assert_eq!(keys, ["feedback/tabs", "project/ci-runner"]); + assert_eq!(left_out.len(), 1); + assert!(left_out[0].reason.contains("unattributed")); + + let raw = export(&agent, &filter).await; + let opened = bundle::open(&raw).await.unwrap(); + assert_eq!( + opened.filter["basis"], + serde_json::json!(["declared", "verified"]) + ); + assert_eq!(opened.memories.len(), 2); +} diff --git a/tests/memory_roundtrip.rs b/tests/memory_roundtrip.rs index e828ad3..0e966a3 100644 --- a/tests/memory_roundtrip.rs +++ b/tests/memory_roundtrip.rs @@ -581,6 +581,7 @@ async fn a_saved_memory_is_signed_and_comes_back_attributed_to_this_agent() { author: me.clone(), kind: Some(AuthorKind::Agent), by_this_agent: true, + imported: None, } ); let summary = got.summary(); @@ -615,6 +616,7 @@ async fn a_memory_another_writer_saved_names_that_writer() { author: theirs, kind: Some(AuthorKind::Operator), by_this_agent: false, + imported: None, } ); assert_eq!(hits[0].entry.summary()["author"]["kind"], "operator"); @@ -982,6 +984,7 @@ async fn a_replacing_save_keeps_the_previous_version_in_history() { author: first_author, kind: Some(AuthorKind::Agent), by_this_agent: true, + imported: None, }, "the old version keeps its own author's signature" );