A reference implementation of storage-protected AI agents on Tigris and Mastra. Clone, run, learn the pattern, fork to customize. Not a published package — the patterns here are stable; the code shape is one way to express them, and it'll harden through real adoption before any of it gets packaged.
The whole demo is a multi-tenant doc Q&A agent built on Mastra. Every customer interaction runs against an isolated fork of the production bucket. A pluggable validator runs after the agent does its work. If validation passes, the fork is promoted via pointer-swap. If it fails, the fork is quarantined for forensics — production never sees the bad write.
git clone https://github.com/tigrisdata/immutable-agent.git
cd immutable-agent
cp .env.example .env
# fill in TIGRIS_STORAGE_ACCESS_KEY_ID, TIGRIS_STORAGE_SECRET_ACCESS_KEY, OPENAI_API_KEY
npm install
npm run verify-setupverify-setup provisions a tiny temp Tigris workspace, writes and reads a
sentinel object, and tears it down. If that prints green, you're ready.
npm run seed # provisions a fresh source bucket and seeds the doc corpus
npm run dev # runs the agent against a customer queryExpected output:
source bucket: acme-cloud-1788...
customer: cust-42
question: What's the price of your Enterprise plan...
--- agent answer ---
The Enterprise plan is $2,400/month with unlimited users and 10 TB of storage.
Acme Cloud operates in two US regions: us-east-1 and us-west-2.
--- session ---
{ outcome: 'promoted', validation: { passed: true }, ... }
Fork promoted via pointer-swap.
After this, s3://<source>/.tigris-mastra/active names the fork as the
active bucket, and the fork itself contains the agent's note artifact.
The demo ships two failure scenarios, each demonstrating a different validator catching a different class of attack.
(a) Blatant injection — the pattern matcher catches it.
npm run clean
npm run seed:poison # adds data/seed-docs/poison.md with literal injection text
npm run devdocs/poison.md contains the obvious string "ignore previous instructions".
The instructionLeakDetector (validatorIndex 1) scans the corpus and matches
the substring at severity 1.0:
─── session ───
{
outcome: 'quarantined',
validation: {
passed: false,
reason: "instruction-leak: 'ignore previous instructions' in docs/poison.md (severity 1)",
details: { validatorIndex: 1, ... }
}
}
(b) Subtle corruption — the canary catches it.
npm run clean
npm run seed:poison-subtle # replaces docs/pricing.md with semantic corruption
npm run devThis one is the realistic attack: an attacker silently edits docs/pricing.md
to claim Enterprise pricing has been "retired" and bundled into Team. No
injection keywords. The pattern matcher passes. But when the canary asks
"What is the price of the Enterprise plan?" the agent — citing only what the
docs say — answers without "$2,400" anywhere. Canary expects "$2,400" in the
answer, doesn't find it, fails:
─── session ───
{
outcome: 'quarantined',
validation: {
passed: false,
reason: 'canary failed: enterprise-price',
details: { validatorIndex: 0, ... }
}
}
Both runs end the same way: the fork is preserved for forensics, the source
bucket's modified pricing doc is still there (the validator flags but doesn't
heal — operations team investigates via npm run inspect-quarantine), and
production never sees the agent's response based on the corrupted corpus.
npm run inspect-quarantine # list all quarantines
npm run inspect-quarantine -- <bucket-name> # show contents of onenpm run cleanTears down the active (promoted) fork named in .tigris-mastra/active, all
quarantined forks in the registry, and the source workspace. Safe to run any
time — gracefully no-ops if there's nothing to clean.
The session-isolation logic is at src/lib/. It's intentionally
not a published package — clone the repo, edit it, fork it.
| File | What it does |
|---|---|
src/lib/session.ts |
The TigrisSession lifecycle: start (fork) → run → validate → promote or quarantine → teardown. Each agent run is one session. |
src/lib/promote.ts |
Promote strategies: pointer-swap (default; atomic at the pointer file), copy-forward (best-effort per-object), custom (your function). |
src/lib/quarantine.ts |
The registry of quarantined forks at s3://<source>/.tigris-mastra/quarantines.json. Inspect with npm run inspect-quarantine. |
src/lib/validators/ |
Pluggable validator interface plus three starter implementations: canaryQueries, instructionLeakDetector, composeValidators. |
src/lib/mastra/ |
Mastra-specific glue: withTigrisSession (the wrapper), extractBucketContext (read fork creds from RequestContext inside a tool), mergeRequestContext. |
src/lib/types.ts |
Shared types and a top-of-file note recording the API verifications against the underlying SDKs. Read this before changing anything that talks to @tigrisdata/agent-kit. |
The demo itself is at:
| Path | What it does |
|---|---|
src/agents/support-agent.ts |
Mastra Agent with two tools, gpt-4o-mini. |
src/tools/search-docs.ts |
Lists and reads docs/ from the fork bucket. |
src/tools/append-note.ts |
Writes notes/<customer>/<ts>.txt to the fork bucket using scoped credentials. |
src/validators/canary-set.ts |
The composed validator: canary questions about pricing/regions plus instruction-leak scan over docs/. |
src/runs/handle-customer.ts |
The orchestrator entry — wraps the agent and runs one query. This is what npm run dev invokes. |
data/seed-docs/ |
A tiny markdown corpus, including poison.md for the failure-mode demo. |
Three concrete swap points:
1. Replace src/agents/support-agent.ts with your agent.
Use whatever model, tools, and instructions you want. The wrapper doesn't care
what's inside as long as it's a Mastra Agent.
2. Customize src/validators/canary-set.ts for your domain.
Real production canaries are facts your customers will notice if flipped:
pricing, regions, named entities, integration partners, terms of service. The
instruction-leak detector here is a starter — you'll likely want to swap in a
domain-tuned classifier or pair with input filtering at retrieval time.
3. Replace src/runs/handle-customer.ts with your orchestrator.
Whatever shape your "one customer interaction" takes — a webhook handler, a
queue worker, a CLI — wrap your agent with withTigrisSession once and call
generate per interaction.
The patterns in src/lib/ shouldn't need to change for typical adoption. If
you find yourself rewriting session.ts or promote.ts, that's worth a
GitHub issue — your use case might be a hint at where the pattern needs to
generalize.
It is:
- A reference for the storage-protected agent pattern on Tigris + Mastra
- A starting point you fork to customize — the patterns are stable, the code shape is one way to express them
- A working demo with end-to-end tests proving the pattern actually contains a real prompt-injection attack on the doc corpus
It is not:
- A one-line library you
npm install. The code lives insrc/lib/for you to read, modify, and own. - A complete production-ready security control. The validators here are starter scaffolding. A real deployment pairs them with input filtering (Lakera, NeMo Guardrails, or a domain-tuned classifier on retrieval) and a triage workflow for inspecting quarantined forks.
- A package with a stable public API. The patterns are stable; the API shape may evolve as we learn from real adoption. If a package emerges later, this repo is its origin story.
- A defense against prompt injection itself. This contains the blast radius of a successful injection on storage. The injection can still happen.
For a deeper walkthrough of when this pattern fits and when it's overhead, see the Tigris blog post introducing this repo.
If you're adapting this for production, the things that bite people:
- Enable bucket snapshots on the source bucket. Forking is implemented
via snapshots; without
enableSnapshots: true(when provisioning viaagent-kit'screateWorkspace) or the equivalent dashboard setting on an existing bucket,createForkserrors with"Failed to snapshot bucket — Invalid Request". - Always teardown. The wrapper handles this in
finally. If you're usingTigrisSessiondirectly, putawait session.teardown()in afinallyblock. - Use scoped credentials in tools.
extractBucketContextreturns the fork's scoped keys. Don't let tools fall back to root credentials from env. - Provision the orchestrator's credentials separately. The orchestrator needs broad permissions on the source. The agent (via tools) needs only scoped fork credentials. Don't share keys.
- Plan for orphaned forks.
agent-kit0.1.x'screateForksdoesn't accept a TTL. If your orchestrator crashes hard (OOM kill, container eviction), the fork leaks. Add a periodic cleanup script that lists and tears down*-fork-*buckets above some age. - Build a triage workflow for quarantines.
inspect-quarantineis a CLI tool. Production needs alerts, dashboards, and a process for reviewing what got caught. - Pair with input filtering. The validators here are last-line-of-defense. Filter retrieval inputs at ingestion time too.
npm test # all tests
npm run test:unit # mocked unit tests, fast, no creds needed
npm run test:integration # real Tigris + real OpenAI; gated on env varsThe integration suite includes a poison-flow.test.ts that runs the full
demo scenario end to end (seed → clean run promotes → add poison → re-run
quarantines → source unchanged → cleanup). If you change src/lib/, that
test is the gate.
- Tigris docs — agents on Tigris
@tigrisdata/agent-kit—createForks,checkpoint,restore,setupCoordination@tigrisdata/storage— S3-compatible storage SDK with snapshot version support- Mastra docs — the agent framework
MIT — see LICENSE.