Security & Trustworthy AI

What it protects, what it catches, and how to add the rest.

An agent reads untrusted text and can act on it. So the questions are what it can do, what it keeps, whose data it touches, and what you can prove afterwards. Every sample here runs in our tests, so copy it or hand it to your coding agent.

What it can do

Decide who can call what

One role map decides which tools the model can see and which calls can run. A refused call never reaches the tool, and the refusal goes on the record.

It decides which tools the model may use, not why the model called them.

Governance and policy
import { Agent } from 'agentfootprint';import { gatedTools, staticTools } from 'agentfootprint/providers';import { PermissionPolicy } from 'agentfootprint/security'; const policy = PermissionPolicy.fromRoles(  {    readonly: ['get_refund'],    support: ['get_refund', 'process_refund'],  },  'readonly', // the role for this request); const agent = Agent.create({ provider, model: 'mock', permissionChecker: policy }) // refused if called  .toolProvider(gatedTools(staticTools([getRefund, processRefund]), (name) => policy.isAllowed(name))) // hidden from the model  .build();

What it keeps

Keep what you name out of the record

Redaction is opt-in. Turn it on and the conversation, plus the fields you name, stay out of every record the library keeps or serves about the run.

Keep personal data out of the record
import { recordRun } from 'agentfootprint/observe';import { conversationRedaction } from 'agentfootprint/security'; const agent = Agent.create({  provider,  model: 'mock',  // The conversation, and the fields your own tools name.  redact: conversationRedaction({ patterns: [/ssn|email/i] }),})  .tool(lookup)  .build(); const recorder = recordRun(agent);await agent.run({ message: 'Look up Dana.' });const recording = recorder.toRecording(); // what you store or send
Kept outStill the real values, and how to change that
The snapshot, the commit log and the narrativeWhat the model is sent, and what your tools receive: clean what the model reads
Every typed event, including what attached recorders and exporters getThe answer run() returns, and a host’s streamed reply, which your app needs
Recordings, bug reports and answer accountsThe final answer inside a saved recording: remove it before storing
The self-explain toolsWhat a memory store keeps for later runs: filter what goes in

Clean what the model reads

Redaction leaves what the model receives alone. To change that, rewrite the message before the run commits it, and each tool result before the model reads it.

Middleware
import { Agent, allow, type MessageMiddleware, type ToolMiddleware } from 'agentfootprint'; const SSN = /\b\d{3}-\d{2}-\d{4}\b/g; // The message, before the run commits it.const scrubSSNs: MessageMiddleware = {  name: 'scrub-ssns',  onMessage: (message) => {    const clean = message.content.replace(SSN, '[ssn]');    return clean === message.content ? allow() : allow(clean, 'masked an SSN');  },}; // Every tool result, before the model reads it, at any depth.const stripPII: ToolMiddleware = {  name: 'strip-pii',  onToolResult: (call) => {    const before = JSON.stringify(call.result);    const after = before.replace(SSN, '[ssn]');    return after === before ? allow() : allow(JSON.parse(after), 'masked an SSN');  },}; const agent = Agent.create({ provider, model: 'mock' })  .tool(lookup)  .act({ input: [scrubSSNs], afterTool: [stripPII] })  .build();

Keep data out of a memory store

Redaction doesn’t reach a memory store, because memory is what the next run recalls. So filter what goes in. A small subclass does it.

Memory and stores
import { InMemoryStore, MEMORY_STRATEGIES, MEMORY_TYPES, defineMemory, type MemoryStore } from 'agentfootprint/memory'; const scrub = <T,>(value: T): T => JSON.parse(JSON.stringify(value).replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[ssn]')); // Every way into the store goes through your filter. Extend RedisStore the same way.class ScrubbingStore extends InMemoryStore {  override put: MemoryStore['put'] = (identity, entry, options) => super.put(identity, scrub(entry), options);  override putMany: MemoryStore['putMany'] = (identity, entries, options) =>    super.putMany(identity, scrub(entries), options);  override putIfVersion: MemoryStore['putIfVersion'] = (identity, entry, expectedVersion, options) =>    super.putIfVersion(identity, scrub(entry), expectedVersion, options);  override recordSignature: MemoryStore['recordSignature'] = (identity, signature) =>    super.recordSignature(identity, scrub(signature));  override seen: MemoryStore['seen'] = (identity, signature) => super.seen(identity, scrub(signature));} const store = new ScrubbingStore();const memory = defineMemory({  id: 'recent',  type: MEMORY_TYPES.EPISODIC,  strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 },  store,});

Keep the final answer out of a saved recording

A saved recording carries the final answer, even under redaction. Replace it before you store the recording.

Archive a run
import { Agent } from 'agentfootprint';import { openRecording, recordRun } from 'agentfootprint/observe';import { conversationRedaction } from 'agentfootprint/security'; // Walk the values, because the JSON form escapes quotes and newlines. Also catches text already encoded inside a string.const keepOut = (value: unknown, text: string): unknown => {  if (typeof value === 'string') return value.replaceAll(text, '[kept out]').replaceAll(JSON.stringify(text).slice(1, -1), '[kept out]');  if (Array.isArray(value)) return value.map((item) => keepOut(item, text));  if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, keepOut(item, text)]));  return value;}; const agent = Agent.create({ provider, model: 'mock', redact: conversationRedaction() }).build();const recorder = recordRun(agent);const answer = String(await agent.run({ message: 'Summarize the case.' })); const recording = answer ? keepOut(recorder.toRecording(), answer) : recorder.toRecording(); // Fail closed: never store a recording that still holds the answer.if (answer && JSON.stringify(recording).includes(JSON.stringify(answer).slice(1, -1))) {  throw new Error('The answer is still in the recording.');} const artifacts = openRecording(recording as ReturnType<typeof recorder.toRecording>);

Whose data

Keep each customer’s data apart

Pass an identity on every run. Every memory store call is scoped by it, so another tenant’s data reads as nothing, and forget() removes everything for one identity.

Leave the identity out and everything shares one default, which is fine for a prototype and dangerous in production.

Multi-tenant memory
const acme = { tenant: 'acme', principal: 'ada', conversationId: 'thread-1' };const globex = { tenant: 'globex', principal: 'ada', conversationId: 'thread-1' }; await agent.run({ message: 'Our plan is Enterprise.', identity: acme }); // The same person and thread id under another tenant. Nothing carries over.await agent.run({ message: 'What is our plan?', identity: globex }); // Remove everything for one identity.await store.forget(acme);

What it holds

Keep secrets away from the model

A tool declares what it needs. The credential is resolved when that tool runs and handed to that tool alone, so it never passes through the prompt or the record.

Identity and credentials
const createTicket = defineTool({  name: 'create_ticket',  description: 'File a support ticket.',  inputSchema: { type: 'object', properties: {} },  needs: { credential: 'helpdesk' }, // what it needs, not the secret itself  execute: (_args, ctx) => postToHelpdesk(ctx.credential!.toHeaders()), // this tool, at this moment}); const agent = Agent.create({  provider,  model: 'mock',  credentials: staticTokens({ helpdesk: process.env.HELPDESK_TOKEN! }), // or vaultCredentials(), or your own})  .tool(createTicket)  .build();

Who decides

Have a person approve the big ones

A tool can require a check-in. The run pauses before the tool executes, and the person sees what it will do, what it read and what drove the choice. Their decision goes on the record.

Check in with the receipts
import { Agent, checkInApproved, defineTool, isCheckInPause } from 'agentfootprint'; const issueRefund = defineTool<{ amount: number }, string>({  name: 'issue_refund',  description: 'Issue a refund to the customer.',  inputSchema: { type: 'object', properties: { amount: { type: 'number' } }, required: ['amount'] },  checkIn: (args) => args.amount > 1000, // a person decides on the big ones  execute: ({ amount }) => {    refunded.push(amount);    return `refunded ${amount}`;  },}); const agent = Agent.create({ provider, model: 'mock' }).tool(issueRefund).build(); const outcome = await agent.run({ message: 'Refund $5,000 on A-1042.' }); if (isCheckInPause(outcome)) {  // The tool has not run. Show the person outcome.checkIn: what it will do, what it read, what drove this.  const decision = checkInApproved({ request: outcome.checkIn, by: 'ada@ops' });  await agent.resume(outcome.checkpoint, decision);}

What it catches

It checks the model, the provider and the tools

Each check compares what happened against the record or your declaration. How it stands explains who decides each one.

FromWhat it catchesTurn it on withWhat happens
The modelArguments of the wrong shapeinputSchemaThe tool doesn’t run, and the model is told
The modelA tool input no earlier result gaveargumentsFromOn the record, and the tool still runs
The modelA name or number no tool returned.namesAndNumbersFromEvidence()Flagged, and it can re-ask
The modelA claim that contradicts a tool’s facts.claims()On the record, and it can withhold the answer
The providerA refusal or content filterstopReasonOn the record, and your code decides
A toolRows that break their declared columnsresultColumnsOn the record
A toolA lookup that comes back emptynoticeEmptyLookupsAn advisory on the record
An MCP toolA result that breaks its schemaoutputSchemaRefused

Most of them are one line

Three more protections are always on:

  • Text a tool returns can’t steer the run. Control goes through a typed result, not a string like ROUTE:billing.
  • Memory and parallel-branch text is escaped, so it can’t break out of its own block.
  • A rejected draft comes back to the model marked as quoted data, not as instructions.
Context Integrity
import { Agent, defineTool } from 'agentfootprint'; const searchOrders = defineTool({  name: 'search_orders',  description: 'Find a customer’s orders.',  inputSchema: { type: 'object', properties: {} },  execute: () => [{ orderId: 'A-1042' }],}); const getRefund = defineTool({  name: 'get_refund',  description: 'Look up a refund.',  inputSchema: { type: 'object', properties: { orderId: { type: 'string' } }, required: ['orderId'] }, // the wrong shape never runs  argumentsFrom: ['search_orders'], // its input should come from that tool  resultColumns: { orderId: 'string', amount: 'number' }, // what its rows should hold  execute: () => [{ orderId: 'A-1042', amount: 'a lot' }],}); const agent = Agent.create({ provider, model: 'mock', checkColumnTypes: 'warn' })  .tool(searchOrders)  .tool(getRefund)  .namesAndNumbersFromEvidence() // every name and number in the answer  .build(); // Findings from the model and from tools arrive on one stream.agent.on('agentfootprint.integrity.context_error', (event) => flagged.push(event.payload.kind)); // What the provider itself said, such as 'refusal' or 'content_filter'.agent.on('agentfootprint.stream.llm_end', (event) => stops.push(event.payload.stopReason));

Prompt injection

Add your own check for injected instructions

The library has no classifier for injected instructions, and a steered model can still use any tool it’s allowed. So plug yours in at the moment untrusted text arrives, as in this sample. Here’s what limits the damage either way:

  • Visibility. gatedTools hides tools from the model, recomputed every iteration.
  • A gate at execution. A permission checker runs before every tool call, with its arguments, history and caller.
  • Argument validation. What the model produces is checked against the tool’s schema before it runs.
  • Credential scoping. A hijacked call to one tool can’t read another tool’s credential.
  • Evidence. Each layer fires typed events, and they can go into an audit bundle.
Prompt injection in the docs
import { Agent, allow, type ToolMiddleware } from 'agentfootprint'; // Your classifier, your rule. The library gives it the right moment.const quarantine: ToolMiddleware = {  name: 'quarantine',  onToolResult: (call) =>    looksLikeInjection(JSON.stringify(call.result)) // objects too, not just strings      ? allow('[removed: this text tried to give instructions]', 'looked like an injection')      : allow(),}; const agent = Agent.create({ provider, model: 'mock' })  .tool(readTicket)  .act({ afterTool: [quarantine] })  .build();

Stop a dangerous chain of tools

Each call can be fine while the chain isn’t: read a customer file, then email it out. A checker sees the calls made so far and can end the run.

Sequence governance
import { Agent, type PermissionChecker } from 'agentfootprint'; // Each tool is allowed on its own. This chain is not.const noExfiltration: PermissionChecker = {  name: 'no-exfiltration',  check: ({ target, sequence }) =>    target === 'send_email' && sequence?.some((call) => call.name === 'read_customer_file')      ? { result: 'halt', reason: 'security:exfiltration', tellLLM: 'This combination is restricted.' }      : { result: 'allow' },}; const agent = Agent.create({ provider, model: 'mock', permissionChecker: noExfiltration })  .tool(readCustomerFile)  .tool(sendEmail)  .build(); // The run ends with a PolicyHaltError you can catch, before the email goes out.

What you can prove

Show the record hasn’t changed

auditExport() hash-chains every typed event into a plain JSON bundle. verifyAuditBundle() checks it offline, with no agent, and names the record a change broke.

Payloads stay bounded by default: structure is kept, and prompts and answers become markers.

Exporters
import { auditExport, verifyAuditBundle } from 'agentfootprint/observe'; const audit = auditExport({ agent: 'refunds' });const stop = agent.enable.observability({ strategy: audit });await agent.run({ message: 'Refund A-1042.' });stop(); const bundle = audit.bundle(); // plain JSON, store it anywhereconst check = verifyAuditBundle(bundle); // offline, with no agent// check.valid is true, and check.recordsChecked says how many records it read.// Change one byte anywhere, and valid is false, with brokenAt naming the record.

Not built in, and how to add it

What you needWhat to copy
Detect injected instructionsA guard on what the model is about to read
Stop a dangerous chain of toolsA checker that sees the calls so far
Keep data away from the modelRewrite the message and each tool result
Keep data out of a memory storeA small store subclass that filters every write
Keep the final answer out of a saved recordingReplace it before you store the recording

The library isn’t a compliance certification. It gives you controls and evidence for your own review.