Wrap a deny.sh vault entry as an Agents SDK tool.
The OpenAI Agents SDK runs the tool loop for you: the model emits a tool call, the SDK runs your function, and the result returns to the model. The official deny-sh-openai-agents adapter resolves the credential inside that function, makes the privileged call, and returns only a narrowed DTO. A fail-closed leak sweep throws if the raw secret ever appears in the return, so the key never enters the model's context window.
Prerequisites
- Recommended: the official OpenAI Agents adapter —
pip install deny-sh-openai-agents openai-agents(Python) ornpm i deny-sh-openai-agents @openai/agents zod(TypeScript). It wraps the core SDK with a fail-closed leak sweep so the raw secret never crosses back to the model. - Note (TypeScript):
@openai/agentspeers zod v4 (the LangChain and Vercel adapters use zod v3). Installzodalongside so it resolves to v4. - A deny.sh tenant API key + a vault entry under it (see /dashboard/vault).
- A vault wrap password (
VAULT_PW) in your server environment, never the agent prompt.
Python: deny_vault_tool + Agent
Show code — ~32 lines, python
# pip install deny-sh-openai-agents openai-agents requests
import os, requests
from pydantic import BaseModel
from agents import Agent, Runner
from deny_sh_openai_agents import deny_vault_tool
class InvoiceArgs(BaseModel):
id: str
def lookup(stripe_key: str, args: dict) -> dict:
# resolve happens before this runs; stripe_key is live, in-scope only
r = requests.get(f"https://api.stripe.com/v1/invoices/{args['id']}",
headers={"Authorization": f"Bearer {stripe_key}"})
body = r.json()
# narrowed DTO: never the raw key, never the raw upstream body
return {"id": body.get("id"), "amount_due": body.get("amount_due"),
"status": body.get("status")}
invoice_tool = deny_vault_tool(
label="stripe-prod", # or: id="item_abc"
password=os.environ["VAULT_PW"], # server env, never the prompt
name="get_invoice",
description="Look up a Stripe invoice by id",
args_schema=InvoiceArgs,
use=lookup,
)
agent = Agent(
name="Billing",
instructions="Help the user with their invoices.",
tools=[invoice_tool],
)
result = Runner.run_sync(agent, "What is the status of invoice in_1Abc123XYZ?")
print(result.final_output)Why this is the deniable shape
The Stripe key is resolved + consumed entirely inside lookup. The Agents SDK and the model provider see only the input args ({ id }) and the narrowed return DTO. The key never crosses any wire that isn't deny.sh → your server → Stripe. If use ever returns the raw secret, the leak sweep raises DenyLeakError and the secret never crosses back into the model context.
TypeScript: denyVaultTool + Agent
Show code — ~33 lines, typescript
// npm i deny-sh-openai-agents @openai/agents zod (zod resolves to v4)
import { Agent, run } from '@openai/agents';
import { denyVaultTool } from 'deny-sh-openai-agents';
import { z } from 'zod';
const getInvoice = denyVaultTool({
label: 'stripe-prod', // or: id: 'item_abc' (skips the label scan)
password: process.env.VAULT_PW!, // server env, never the prompt
name: 'get_invoice',
description: 'Look up a Stripe invoice by id',
parameters: z.object({ id: z.string() }),
use: async (stripeKey, { id }) => {
const r = await fetch(`https://api.stripe.com/v1/invoices/${id}`, {
headers: { Authorization: `Bearer ${stripeKey}` },
});
if (!r.ok) return { error: 'invoice_lookup_failed', status: r.status };
const body = await r.json();
// narrowed DTO — never the raw key, never the raw upstream body
return { id: body.id, amount_due: body.amount_due, status: body.status };
},
});
const agent = new Agent({
name: 'Billing',
instructions: 'Help the user with their invoices.',
tools: [getInvoice],
});
const result = await run(agent, 'What is the status of invoice in_1Abc123XYZ?');
console.log(result.finalOutput);Fail-closed by design
The Agents SDK's tool() default error handler swallows thrown errors into a self-heal string for the model to retry. The adapter sets errorFunction: null so the (already secret-scrubbed) DenyToolError / DenyLeakError propagate loud and fail-closed instead of being silently fed back to the model. Same security model as the LangChain and Vercel adapters; only the wiring differs.
Multi-tenant
For per-tenant agent platforms, build the tool inside a closure that captures the tenant context. The closure scopes the deny.sh API key, vault label, and tenant password together so no fallback to a shared key is possible.
Show code — ~12 lines, typescript
const getInvoiceFor = (tenantId: string, tenantPw: string) =>
denyVaultTool({
label: 'stripe-prod',
password: tenantPw,
clientOptions: { apiKey: tenantKeyFor(tenantId) },
name: 'get_invoice',
description: 'Look up an invoice for the current tenant',
parameters: z.object({ id: z.string() }),
use: async (key, { id }) => { /* ... narrowed DTO ... */ },
});
// per-request: build the agent fresh with this tenant's tool
const agent = new Agent({ name: 'Billing', tools: [getInvoiceFor(tid, pw)] });Two tenants holding the same product cannot decrypt each other's vault entry, even if the labels collide. The deniability boundary is cryptographic, not policy-based; a leak of one tenant's vault password compromises that tenant only.
Troubleshooting
The errors a launch-day developer hits when copy-pasting the snippets above. Click to expand the fix.
peer dep zod@^4 from @openai/agents / two zod versions installed
The OpenAI Agents SDK peers zod v4. The LangChain and Vercel adapters peer zod v3, so a monorepo that uses several adapters can end up with both. The z.object() you pass to denyVaultTool must be the same zod major the SDK resolved.
Fix: in a project using only this adapter, npm i zod resolves v4 cleanly. In a mixed workspace, isolate the OpenAI agent in its own package, or pin zod to v4 at the workspace root and confirm the other adapters tolerate it.
tool error was swallowed, agent retried instead of failing
You rebuilt the tool by hand with tool() instead of using denyVaultTool. The SDK's default errorFunction converts thrown errors into a string the model sees and retries, which defeats fail-closed behaviour.
Fix: use the adapter (it sets errorFunction: null). If you must hand-roll, pass errorFunction: null yourself so DenyToolError / DenyLeakError propagate.
DenyLeakError: narrowed DTO contained the raw secret
This is the leak sweep doing its job. Your use function returned an object that still contains the resolved key somewhere (often a raw upstream response body, an echoed auth header, or an error stack).
Fix: return only the specific fields the model needs. Never spread the whole upstream body or include response headers. The sweep walks the return value (including nested objects and strings) and throws before anything crosses back to the model.
vault error: label_not_found when the password is wrong
A wrong vault password returns label_not_found, not invalid_password. That's deliberate; the wire shape is identical so an attacker can't enumerate which labels exist by probing passwords.
Fix: confirm the tenant password matches what was used in vault_store. If you've rotated, re-store every label under the new password; old ciphertexts won't decrypt with the new one.
ModuleNotFoundError: No module named 'agents' (Python)
The Python package is openai-agents but the import is agents. Installing deny-sh-openai-agents alone doesn't pull the SDK; it's a peer.
Fix: pip install openai-agents (Python ≥ 3.10). Then from agents import Agent, Runner resolves.
Production checklist
- Set up /dashboard/decoy-alerts with a SHA-256 of a decoy controlData. Get paged when a prompt-injection extracts the decoy.
- Wire /dashboard/webhooks to your existing Datadog / PagerDuty / Slack.
- Pull audit-chain receipts via
GET /v1/audit/receipt/<id>or browse them at /dashboard/audit for regulator-grade evidence. - Use /dashboard/byok if you need customer-managed KMS keys.