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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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.2.1",
"version": "0.3.0",
"keywords": [
"memory",
"vta",
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "vta-agent-memory"
version = "0.2.1"
version = "0.3.0"
edition = "2024"
rust-version = "1.95.0"
description = "Agentic memory for Claude Code, stored in a Verifiable Trust Agent"
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,11 @@ pnm acl delete --did <the agent DID setup printed>

`--use-session` skips the minting and reuses your own login instead. It stores no
key, but the memory service then inherits your whole reach, and revoking it means
revoking your login.
revoking your login. For that reason the MCP server, the `SessionStart` hook and
the `recall`/`list`/`forget`/`doctor` commands **refuse a config written this
way** unless `VTA_AGENT_MEMORY_ALLOW_OPERATOR_LOGIN=1` is set in the environment
they run in (for the plugin, the environment Claude Code is started from). The
default dedicated agent needs no such setting.

Reach for it when the VTA advertises no DIDComm mediator — a REST-only VTA — the
one case where a `did:key` agent has no way in. If that VTA has a mediator that
Expand Down Expand Up @@ -215,6 +219,16 @@ person accumulates. If that stops being true, the fix is upstream — a
`trustoverip/dtgwg-trust-tasks-tf`, because the VTA's dispatcher refuses URIs the
published registry has no schema for.

**What a save can hold, and how much of it a session gets.** A memory is a note,
not a document: `memory_save` refuses a name over 120 characters, a description
over 300, a body over 16 KiB, more than 32 links, or a link over 120 characters.
`memory_list` returns one page — 50 by default, at most 200 — with `total` and a
`nextOffset` for the next one. The `SessionStart` hook caps what it injects at
32 KiB and says inside the fence when it had to truncate, because that text lands
in the context before the user has typed anything. The limits are checked where a
record comes in, never on decode, so a memory stored before they existed — or by
another tool — stays readable and forgettable.

**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 —
Expand Down
3 changes: 2 additions & 1 deletion commands/forget.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ If the argument is already a key (`type/name`), confirm what it is with
guess when more than one matches.

If they have asked to forget everything, run `memory_list` first, show them what
is there, and confirm before deleting anything.
is there, and confirm before deleting anything. `memory_list` is paged: keep
calling it with `nextOffset` until there is none, so nothing is missed.
4 changes: 4 additions & 0 deletions commands/memories.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@ Run `memory_context` and `memory_list`. Report the trust context the memories
live in, then the memories grouped by type, as name + description — not full
bodies.

`memory_list` returns one page (50 by default) and a `total`. If `nextOffset` is
set there are more: say how many in all, and fetch further pages with `offset`
only if the user wants to see them.

If the list is empty, say which context you looked in, since an empty result
usually means the wrong context rather than no memories.
12 changes: 10 additions & 2 deletions skills/agent-memory/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ stored.
Link related memories by name in the `links` field. A link to a memory that does
not exist yet is fine — it marks something worth writing later.

A memory is a note, not a document, and what one save stores can be pasted into
every later session. So a save is bounded: the name is at most 120 characters,
the description 300, the body 16 KiB, and there can be at most 32 links of 120
characters each. A save over any of those is refused and says which limit it hit.
Keep the essentials in the body and save a pointer to where the rest lives.

## What not to save

- **Anything the repository already records.** Code structure, past fixes, git
Expand Down Expand Up @@ -139,8 +145,10 @@ supersedes the old one. If a memory turns out to be wrong, forgetting it is
right — a wrong memory is worse than a missing one.

If the user asks you to forget *everything*, confirm the scope first, list what
is there with `memory_list`, and only then delete. It is a small number of calls
and it is not reversible.
is there with `memory_list`, and only then delete. `memory_list` returns one page
and a `total`: keep calling it with the previous result's `nextOffset` until there
is none, or you will delete only the first page of what you showed them. It is a
small number of calls and it is not reversible.

## Setup problems

Expand Down
64 changes: 64 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ impl Identity {
"dedicated agent (rotated)"
}
}

/// Refuse an operator login unless this process has opted in to it.
///
/// The MCP server and the `SessionStart` hook run unattended in every
/// session and act with whatever the configured identity can reach. For a
/// dedicated agent that is one trust context. For an operator login it is
/// everything the operator can do on the VTA. So that mode needs an
/// explicit opt-in in the environment it runs in, not just a config file
/// that was written once.
pub fn ensure_permitted(&self, allow_operator_login: bool) -> anyhow::Result<()> {
if self.operator_login && !allow_operator_login {
anyhow::bail!(
"this memory config reuses the operator's own `pnm` login (written by `setup \
--use-session`), which gives the memory service everything that login can \
reach. It is refused unless {ALLOW_OPERATOR_LOGIN_ENV}=1 is set in the \
environment this runs in.\n\nTo switch to a dedicated agent scoped to one trust \
context instead:\n vta-agent-memory setup --force"
);
}
Ok(())
}
}

/// Set to `1` to let the MCP server and the connecting CLI commands use an
/// operator-login config. See [`Identity::ensure_permitted`].
pub const ALLOW_OPERATOR_LOGIN_ENV: &str = "VTA_AGENT_MEMORY_ALLOW_OPERATOR_LOGIN";

/// Whether [`ALLOW_OPERATOR_LOGIN_ENV`] is set to opt in.
pub fn operator_login_allowed_by_env() -> bool {
is_opt_in(std::env::var(ALLOW_OPERATOR_LOGIN_ENV).ok().as_deref())
}

/// `1` or `true` (any case) opts in. Anything else, including unset, does not.
fn is_opt_in(value: Option<&str>) -> bool {
value.is_some_and(|v| {
let v = v.trim();
v == "1" || v.eq_ignore_ascii_case("true")
})
}

/// Write `contents` to `path`, creating parents, readable only by its owner.
Expand Down Expand Up @@ -273,4 +311,30 @@ mod tests {
"absent means the safer reading"
);
}

#[test]
fn an_operator_login_needs_the_explicit_opt_in() {
// A dedicated agent needs nothing.
assert!(agent_config().identity.ensure_permitted(false).is_ok());

let mut cfg = agent_config();
cfg.identity.operator_login = true;
let err = cfg.identity.ensure_permitted(false).unwrap_err();
assert!(
err.to_string().contains(ALLOW_OPERATOR_LOGIN_ENV),
"the refusal must name the opt-in: {err}"
);
assert!(cfg.identity.ensure_permitted(true).is_ok());
}

#[test]
fn only_an_affirmative_value_opts_in() {
for yes in ["1", "true", "TRUE", " 1 "] {
assert!(is_opt_in(Some(yes)), "{yes:?}");
}
for no in ["", "0", "false", "no", "yes please"] {
assert!(!is_opt_in(Some(no)), "{no:?}");
}
assert!(!is_opt_in(None));
}
}
1 change: 1 addition & 0 deletions src/enrol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ pub async fn connect(args: ConnectArgs) -> anyhow::Result<SetupOutcome> {
vta_did: pending.vta_did,
context_id: pending.context_id,
identity_label: cfg.identity.label().to_string(),
operator_login: cfg.identity.operator_login,
agent_did: rotated_did,
memories_found,
})
Expand Down
7 changes: 7 additions & 0 deletions src/fence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ impl Fence {
format!("<<</{SENTINEL}:{}>>>", self.nonce)
}

/// The statement placed above the opening delimiter, saying that what
/// follows is data. For callers that emit the parts separately rather than
/// through [`wrap`](Self::wrap).
pub fn preamble(&self) -> &'static str {
self.provenance.preamble()
}

/// Neutralise any text that resembles one of this module's delimiters, so
/// stored content cannot appear to open or close a fence — its own or
/// anyone else's. A zero-width-free, visible substitution: the reader can
Expand Down
79 changes: 78 additions & 1 deletion src/lazy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ pub struct LazyStore {
config_path: std::path::PathBuf,
config: OnceCell<Config>,
store: OnceCell<Store>,
/// Whether an operator-login config may be used. Read from the environment
/// once, at construction. See [`Identity::ensure_permitted`].
///
/// [`Identity::ensure_permitted`]: crate::config::Identity::ensure_permitted
allow_operator_login: bool,
}

impl LazyStore {
Expand All @@ -46,9 +51,38 @@ impl LazyStore {
config_path: config_path.into(),
config: OnceCell::new(),
store: OnceCell::new(),
allow_operator_login: crate::config::operator_login_allowed_by_env(),
}
}

/// A store that is already connected. Lets the MCP tools be exercised
/// against the SDK's in-process loopback transport.
#[cfg(test)]
pub(crate) fn connected(store: Store) -> Self {
Self {
config_path: std::path::PathBuf::new(),
config: OnceCell::new(),
store: OnceCell::new_with(Some(store)),
allow_operator_login: false,
}
}

/// Override the environment's operator-login opt-in, so tests do not
/// depend on (or change) the process environment.
#[cfg(test)]
fn allowing_operator_login(mut self, allow: bool) -> Self {
self.allow_operator_login = allow;
self
}

/// The loaded config, provided its identity is one this process may
/// connect as. Checked before any connection is attempted.
async fn usable_config(&self) -> anyhow::Result<&Config> {
let cfg = self.config().await?;
cfg.identity.ensure_permitted(self.allow_operator_login)?;
Ok(cfg)
}

/// The loaded config. Cheap — a local file read, no network.
///
/// Kept separate from [`store`](Self::store) so diagnostics can answer
Expand All @@ -67,7 +101,7 @@ impl LazyStore {
pub async fn store(&self) -> anyhow::Result<&Store> {
self.store
.get_or_try_init(|| async {
let cfg = self.config().await?.clone();
let cfg = self.usable_config().await?.clone();
let context_id = cfg.context_id.clone();

// The connect future is **not `Send`**: the session rung goes
Expand Down Expand Up @@ -176,4 +210,47 @@ mod tests {
assert_eq!(cfg.identity.vta_did(), "did:key:zV");
assert!(!lazy.is_connected(), "reading config must not connect");
}

fn operator_login_config(dir: &tempfile::TempDir) -> std::path::PathBuf {
let path = dir.path().join("config.json");
std::fs::write(
&path,
r#"{"version":1,"contextId":"proj","identity":{"serviceName":"pnm-cli",
"sessionKey":"vta:mine","sessionsDir":"/nonexistent","vtaDid":"did:key:zV",
"operatorLogin":true}}"#,
)
.unwrap();
path
}

#[tokio::test]
async fn an_operator_login_is_refused_before_connecting_without_the_opt_in() {
let dir = tempfile::tempdir().unwrap();
let lazy = LazyStore::new(operator_login_config(&dir)).allowing_operator_login(false);

let Err(err) = lazy.store().await else {
panic!("an operator login must not produce a store without the opt-in");
};
assert!(
format!("{err:#}").contains(crate::config::ALLOW_OPERATOR_LOGIN_ENV),
"the refusal must name the opt-in, since it reaches a person via the model: {err:#}"
);
assert!(!lazy.is_connected());

// Diagnostics still work: `memory_context` reads the config directly.
assert!(lazy.config().await.unwrap().identity.operator_login);
}

#[tokio::test]
async fn with_the_opt_in_an_operator_login_goes_on_to_connect() {
// Stops short of connecting, which would need a real session: the
// config `store()` would connect with is handed over.
let dir = tempfile::tempdir().unwrap();
let lazy = LazyStore::new(operator_login_config(&dir)).allowing_operator_login(true);
let cfg = lazy
.usable_config()
.await
.expect("permitted with the opt-in");
assert!(cfg.identity.operator_login);
}
}
Loading
Loading