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 policyimport { 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 recordimport { 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 out | Still the real values, and how to change that |
|---|---|
| The snapshot, the commit log and the narrative | What the model is sent, and what your tools receive: clean what the model reads |
| Every typed event, including what attached recorders and exporters get | The answer run() returns, and a host’s streamed reply, which your app needs |
| Recordings, bug reports and answer accounts | The final answer inside a saved recording: remove it before storing |
| The self-explain tools | What 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.
Middlewareimport { 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 storesimport { 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 runimport { 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 memoryconst 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 credentialsconst 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 receiptsimport { 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.
| From | What it catches | Turn it on with | What happens |
|---|---|---|---|
| The model | Arguments of the wrong shape | inputSchema | The tool doesn’t run, and the model is told |
| The model | A tool input no earlier result gave | argumentsFrom | On the record, and the tool still runs |
| The model | A name or number no tool returned | .namesAndNumbersFromEvidence() | Flagged, and it can re-ask |
| The model | A claim that contradicts a tool’s facts | .claims() | On the record, and it can withhold the answer |
| The provider | A refusal or content filter | stopReason | On the record, and your code decides |
| A tool | Rows that break their declared columns | resultColumns | On the record |
| A tool | A lookup that comes back empty | noticeEmptyLookups | An advisory on the record |
| An MCP tool | A result that breaks its schema | outputSchema | Refused |
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.
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.
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 governanceimport { 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.
Exportersimport { 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 need | What to copy |
|---|---|
| Detect injected instructions | A guard on what the model is about to read |
| Stop a dangerous chain of tools | A checker that sees the calls so far |
| Keep data away from the model | Rewrite the message and each tool result |
| Keep data out of a memory store | A small store subclass that filters every write |
| Keep the final answer out of a saved recording | Replace it before you store the recording |
The library isn’t a compliance certification. It gives you controls and evidence for your own review.
