Skip to content

Bound what a memory can hold and what a session is handed, page memory_list, and gate the operator login - #17

Merged
stormer78 merged 1 commit into
mainfrom
sec-4045/mcp-hardening
Sep 12, 2026
Merged

stormer78 merged 1 commit into
mainfrom
sec-4045/mcp-hardening

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Five related pieces of hardening on the MCP surface and the SessionStart hook.
None of them change the documented default flow: setup (dedicated,
context-scoped agent) → serve → recall.

Size limits on memory_save

name 120 characters, description 300, body 16 KiB, at most 32 links of
120 characters each. Over any of those the save is refused with
invalid_params, naming the limit it hit, before the store is touched — so
being told no does not depend on the VTA being reachable.

The limits are checked where a record comes in, never on decode. A memory stored
before they existed, or by another tool, stays readable and forgettable.

A cap on what the hook injects

Session-start context is capped at 32 KiB including the preamble and delimiters,
with a truncation marker inside the fence saying how many of how many
memories were shown and where to get the rest. Saves are bounded now, but a
context can hold hundreds of memories, so the rendered total needs its own
bound. A person who ran recall at a terminal is not capped.

memory_list returns a page

limit (default 50, clamped to 1..200) and offset, and the result carries
total, offset, limit, count and nextOffset. /memories and /forget
now tell the model to follow nextOffset: confirming "forget everything" against
a single unpaged list would have shown the user 50 memories and deleted only
those.

Read results are fenced, like the hook's output

memory_recall, memory_get and memory_list return three content blocks —
preamble + opening delimiter, the JSON, the closing delimiter — reusing the same
nonce-carrying fence the CLI hook path already used. Separate blocks keep the
JSON machine-parseable. The serialised JSON is sanitised again on the way out, so
no field can carry a delimiter shape whichever projection it came from; a
delimiter token contains no " or \, and neither does its replacement, so the
JSON stays valid.

Fields were already sanitised and labelled "trust": "untrusted-data", so this
is defence in depth rather than a fix for an open hole.

Tool annotations clients can gate on

destructiveHint on memory_forget (permanent, no undo) and on memory_save
(the same name replaces an existing memory), readOnlyHint on memory_recall,
memory_get, memory_list and memory_context. Asserted both through the tool
router and on the wire, spelled the way the protocol spells them.

setup --use-session now needs an explicit opt-in to be used

That flag stores no key but authenticates as the operator's own pnm login, so
the memory service inherits everything that login can reach. The MCP server and
the SessionStart hook run unattended in every session, which is the wrong place
for that reach to be implicit in a config file written once.

So serve, the hook, and recall/list/forget/doctor refuse such a config
unless VTA_AGENT_MEMORY_ALLOW_OPERATOR_LOGIN=1 is set in the environment they
run in. The refusal is shaped by who is reading it:

  • the hook stays silent and exits 0 — it must never fail a session;
  • a person gets an error naming the env var and setup --force;
  • memory_context still answers, because diagnostics must work when the rest
    does not;
  • setup --use-session warns, at the moment it writes the config, that this is
    what will happen.

The default dedicated-agent path needs no setting and is unchanged.

Tests

  • Oversize name, description, body, link count and single link are each refused
    with invalid_params against a server with no config, proving the check runs
    before the store; exactly at every limit the save proceeds and fails only for
    want of a configured VTA.
  • 300 memories through the SDK's loopback transport: default page is 50 with
    total: 300 and nextOffset: 50, offset walks the pages, limit clamps at
    200 and at 1, the last page reports no nextOffset, and an offset past the end
    returns an empty page with the real total.
  • memory_get and memory_recall: exactly one matching open/close pair, the
    preamble first, the JSON block parsing on its own, and a body and link carrying
    this crate's own delimiter shape neutralised while the injected prose stays
    readable.
  • Annotations asserted through the router and over stdio from the real binary.
  • The hook cap: 100 × 16 KiB bodies render under 32 KiB with the marker inside
    the fence; a render that fits is untouched; a single oversized legacy body is
    cut on a char boundary rather than dropped; the terminal path is uncapped.
  • An operatorLogin: true config produces no store and names the env var, and
    the real binary's hook path exits 0 with empty stdout while recall fails
    loudly.

Versions

Plugin and crate versions bumped in step (0.2.0 → 0.3.0): tool schemas,
descriptions, the skill and the slash commands are all plugin-visible, and
claude plugin update compares that version rather than the commit.

Not in this PR

Rate limiting on memory_save/memory_forget is deliberately absent. The caller
is the user's own local model over stdio, so a limit does not address the stated
threat — a manipulated model can call within any limit. The annotations above are
the control that actually helps, because they let the client ask the user.

…tor login

Five related bounds on the MCP surface and the SessionStart hook. The
documented default flow - setup mints a dedicated, context-scoped agent,
serve, recall - is unchanged and needs no new setting.

A memory is a note, not a document, and what one save stores can be pasted
into every later session. So memory_save now 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, with invalid_params naming the limit it hit.
The check runs before the store is touched, so being told no does not
depend on the VTA being reachable. It runs where a record comes in and
never on decode: an entry stored before these limits existed, or written by
another tool, stays readable and forgettable.

Saves being bounded does not bound a render, because a context can hold
hundreds of memories. The hook's output is capped at 32 KiB including the
preamble and delimiters, with a truncation marker inside the fence saying
how many of how many were shown and where the rest is. It lands in the
context before the user has typed anything, which is exactly why it needs
a ceiling. A person who ran recall at a terminal is not capped.

memory_list returns a page: limit (default 50, clamped 1..200) and offset,
with total, offset, limit, count and nextOffset in the result. The
/memories and /forget commands follow nextOffset now - confirming "forget
everything" against one unpaged list would have shown the user 50 memories
and then deleted only those.

memory_recall, memory_get and memory_list are fenced like the hook's
output: preamble plus opening delimiter, the JSON, the closing delimiter,
as three content blocks so the middle one still parses on its own. The
serialised JSON is sanitised again on the way out, so no field can carry a
delimiter shape whichever projection it came from; a delimiter token
contains no quote or backslash, and neither does its replacement, so the
JSON stays valid. Fields were already sanitised and labelled
"trust": "untrusted-data", so this is depth rather than a fix.

Tool annotations say what each tool does to the world, so a client can ask
the user before the irreversible ones: destructiveHint on memory_forget
and on memory_save (the same name replaces an existing memory),
readOnlyHint on recall, get, list and context. A rate limit was considered
and rejected: the caller is the user's own local model over stdio, so a
manipulated model can call within any limit. Client-side confirmation is
the control that helps.

Finally, setup --use-session. It stores no key, but it authenticates as the
operator's own pnm login, so the memory service inherits everything that
login can reach - and the server and hook run unattended in every session,
which is the wrong place for that reach to be implicit in a file written
once. serve, the hook and recall/list/forget/doctor refuse such a config
unless VTA_AGENT_MEMORY_ALLOW_OPERATOR_LOGIN=1 is set in the environment
they run in. The refusal is shaped by who reads it: the hook stays silent
and exits 0, because it must never fail a session; a person gets an error
naming the env var and setup --force; memory_context still answers,
because diagnostics have to work when the rest does not; and setup warns
at the moment it writes such a config.

Tests, behavioural per CLAUDE.md: each oversize field refused against a
server with no config at all (so invalid_params proves the check precedes
the store) and accepted at exactly every limit; 300 memories through the
loopback transport paged by limit and offset, including the clamps, the
last page and an offset past the end; get and recall asserted to have one
matching open/close pair with a body and a link carrying this crate's own
delimiter shape defanged but still readable; annotations asserted through
the router and over stdio from the real binary; the 32 KiB cap with 100
16 KiB bodies, a render that fits, a single oversized legacy body cut on a
char boundary, and the uncapped terminal path; and an operatorLogin config
that yields no store, names the env var, and leaves the hook exiting 0 with
empty stdout.

Plugin and crate versions bumped in step: tool schemas, descriptions, the
skill and the commands are plugin-visible, and claude plugin update
compares that version rather than the commit.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 force-pushed the sec-4045/mcp-hardening branch from 82ca362 to 8ae09d6 Compare September 12, 2026 06:01
@stormer78

Copy link
Copy Markdown
Contributor Author

Rebased onto main after #15 and #16 merged. The only conflict was the version number: #16 released 0.2.1, and this branch had been cut from 0.2.0. Kept this branch's minor bump, so it now reads 0.3.0 in Cargo.toml, Cargo.lock and .claude-plugin/plugin.json.

After the rebase: cargo fmt --all --check clean, cargo clippy --all-targets -- -D warnings clean, tests pass.

@stormer78
stormer78 merged commit 7ee60fe into main Sep 12, 2026
5 checks passed
@stormer78
stormer78 deleted the sec-4045/mcp-hardening branch September 12, 2026 06:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant