@aurite-ai/kahuna-sdk
v0.4.0
Published
Governance SDK for Kahuna — SPIFFE identity, mTLS, and fail-closed action authorization.
Downloads
139
Readme
@aurite-ai/kahuna-sdk
SDK for building Kahuna-governed agents with SPIFFE/SPIRE identity.
Overview
This package provides the infrastructure code needed to integrate with Kahuna's authorization service using mTLS authentication with SPIFFE SVIDs. It eliminates the need to copy-paste 315 lines of boilerplate code across agent implementations.
Key Benefits:
- ✅ Reduces code duplication - 315 lines of infrastructure code maintained centrally
- ✅ Improves correctness - Single source of truth for mTLS, SVID loading, and authorization
- ✅ Accelerates development - New agents integrate in hours instead of days
- ✅ Ensures consistency - All agents use the same tested patterns
- ✅ Simplifies maintenance - Bug fixes and improvements propagate automatically
Installation
npm install @aurite-ai/kahuna-sdkQuick Start
import { KahunaClient, SvidFileLoader } from '@aurite-ai/kahuna-sdk';
// 1. Load SVID files from spiffe-helper sidecar
const loader = new SvidFileLoader(
process.env.SVID_CERT ?? "/tmp/agent-svid/svid.0.pem",
process.env.SVID_KEY ?? "/tmp/agent-svid/svid.0.key",
process.env.SVID_CA ?? "/tmp/agent-svid/bundle.0.pem",
);
const svid = loader.load();
// 2. Create Kahuna client with mTLS authentication
const client = new KahunaClient(
process.env.KAHUNA_URL ?? "https://kahuna-server.kahuna.svc.cluster.local:8443",
svid,
process.env.KAHUNA_SERVER_SPIFFE_ID ?? "spiffe://kahuna.local/server",
);
// 3. Watch for SVID rotation (every 30 minutes)
loader.watchForRotation((newSvid) => {
console.log('SVID rotated, updating client...');
client.updateSvid(newSvid);
});
// 4. Authorize actions before executing them
const response = await client.authorize({
action_id: crypto.randomUUID(),
parameters: {
action_type: 'write_data',
target_resource_type: 'file',
target_resource_id: '/tmp/example.txt',
write_size_bytes: 1024,
is_overwrite: false,
},
submitted_at: new Date().toISOString(),
});
if (response.outcome === 'authorized') {
// Proceed with the action
console.log('Action authorized!');
} else {
// Handle denial
console.error('Action denied:', response.deny_reason_code);
}Core Concepts
SVID Loading
The SvidFileLoader class loads X.509 certificates from the spiffe-helper sidecar:
- Automatic retry logic - Handles cert/key mismatch during rotation
- Certificate-key validation - Uses OpenSSL to verify the pair matches
- File watching - Detects rotation and invokes callbacks
- Directory-based watching - Handles inode replacement during rotation
const loader = new SvidFileLoader(certPath, keyPath, caPath);
const svid = loader.load(); // Synchronous load with retry
loader.watchForRotation((newSvid) => {
// Called when sidecar rotates credentials (every 30 minutes)
client.updateSvid(newSvid);
});mTLS Authentication
The createMtlsAgent function creates an HTTPS agent with SPIFFE ID verification:
- URI SAN validation - Verifies SPIFFE IDs, not DNS names
- Impersonation prevention - Ensures you're talking to the real Kahuna server
- Handshake abortion - Rejects connections before sending request body
import { createMtlsAgent } from '@aurite-ai/kahuna-sdk';
const agent = createMtlsAgent(svid, 'spiffe://kahuna.local/server');
// Use with any HTTPS request
https.request(url, { agent }, (res) => {
// Server's SPIFFE ID was verified during handshake
});Why this matters: Every workload in the trust domain has a CA-signed certificate. Chain verification alone doesn't tell you that you reached Kahuna — the SPIFFE ID check is what prevents impersonation attacks.
Authorization Flow
The KahunaClient class wraps the /v1/actions/authorize endpoint:
- mTLS-authenticated requests - Uses SPIFFE SVIDs for authentication
- SVID rotation support - Updates agent without downtime
- Typed interfaces - TypeScript types for requests and responses
- Error handling - Distinguishes network errors from authorization denials
const client = new KahunaClient(baseUrl, svid, serverSpiffeId);
const response = await client.authorize({
action_id: crypto.randomUUID(),
parameters: {
action_type: 'send_email',
recipient: '[email protected]',
subject: 'Test email',
body_size_bytes: 1024, // Size, NOT content
},
submitted_at: new Date().toISOString(),
});
if (response.outcome === 'authorized') {
// Execute the action
} else {
// Handle denial: response.deny_reason_code
}SVID Rotation
SVIDs are rotated every 30 minutes by the spiffe-helper sidecar. The SDK handles this automatically:
// Set up rotation handler once during initialization
loader.watchForRotation((newSvid) => {
client.updateSvid(newSvid);
});
// The client will use new credentials for all subsequent requests
// Old connections are gracefully closedAPI Reference
SvidFileLoader
Loads SVID files from the spiffe-helper sidecar.
class SvidFileLoader {
constructor(certPath: string, keyPath: string, caPath: string);
load(): SvidFiles;
watchForRotation(callback: (svid: SvidFiles) => void): FSWatcher;
}Methods:
load()- Load SVID files synchronously with retry logic. ThrowsSvidLoadErrorif files cannot be read or cert/key mismatch persists.watchForRotation(callback)- Watch for SVID rotation and invoke callback with new credentials. Returns a watcher that can be closed with.close().
KahunaClient
Client for the /v1/actions/authorize endpoint.
class KahunaClient {
constructor(
baseUrl: string,
svid: SvidFiles,
serverSpiffeId: string,
options?: KahunaClientOptions,
);
updateSvid(svid: SvidFiles): void;
authorize(request: AuthorizeRequest): Promise<AuthorizeResponse>;
}
interface KahunaClientOptions {
timeoutMs?: number; // per attempt, default 5000
maxRetries?: number; // retries reuse the same action_id, default 2
minimize?: boolean; // default true — see below before turning it off
minimization?: MinimizationOptions;
definitions?: DefinitionCache; // custom actions — see "Custom actions"
}Methods:
updateSvid(svid)- Update the SVID after rotation. Call this when the sidecar rotates credentials.authorize(request)- Authorize an action with Kahuna. Returns the authorization response. ThrowsSpiffeIdMismatchErrorif the server presents an unexpected SPIFFE ID, and aKahunaTimeoutError/KahunaHttpError/KahunaTransportError/KahunaValidationErrorfor the corresponding failures.
On minimize. It is on by default and should stay on. The client sends the
policy-relevant projection of your parameters plus a digest of the whole set;
the raw values never leave the workload. { minimize: false } puts the action's
full parameters on the wire — including the ones you would not want in an
authorization log. An action type this build has no tiers for is not an error:
it minimizes to nothing and is sent, so the server can refuse it and record it
for an operator to register. isMinimizable(parameters) answers "does this
build know how to project this?" for diagnostics.
createMtlsAgent
Create an HTTPS agent with SPIFFE ID verification.
function createMtlsAgent(
svid: SvidFiles,
expectedServerSpiffeId: string
): https.Agent;Parameters:
svid- The SVID files (cert, key, CA bundle)expectedServerSpiffeId- The SPIFFE ID the server must present (e.g.,'spiffe://kahuna.local/server')
Returns: An https.Agent configured for mTLS with SPIFFE ID verification.
Types
interface SvidFiles {
cert: string; // PEM-encoded certificate
key: string; // PEM-encoded private key
ca: string; // PEM-encoded CA bundle
}
interface AuthorizeRequest {
action_id: string;
parameters: Record<string, unknown>;
submitted_at: string;
}
interface AuthorizeResponse {
outcome: "authorized" | "denied";
deny_reason_code?: string;
evaluated_policy_ids: string[];
matched_policy_id?: string;
}Errors
class SpiffeIdMismatchError extends Error { // server presented an unexpected SPIFFE ID
constructor(expected: string, actual: string);
}
class SvidLoadError extends Error { // SVID files unreadable or cert/key mismatched
constructor(message: string, cause?: Error);
}
class AuthorizationError extends Error { // the action was refused
constructor(message: string, denyReasonCode?: string);
}
class KahunaTimeoutError extends Error {} // no answer within timeoutMs
class KahunaHttpError extends Error {} // a non-200 from the mediator
class KahunaTransportError extends Error {} // connection refused, DNS, TLS
class KahunaValidationError extends Error {} // a 200 whose body is not a decision
// Narrow without instanceof, which is unreliable across duplicate installs of
// the package (two versions in one tree, or ESM and CJS of the same version).
function isKahunaError(err: unknown): boolean;
function isKahunaErrorOfKind(err: unknown, kind: string): boolean;deny_reason_code is an open set. The SDK does not validate it against a
list, so a newer mediator can add one without breaking clients — which means a
switch over it needs a default. schema_violation, for instance, is what a
catalog-aware mediator answers when a custom action's parameters do not match its
registered schema.
Examples
Basic File Agent
import { KahunaClient, SvidFileLoader } from '@aurite-ai/kahuna-sdk';
import { writeFile } from 'node:fs/promises';
// Initialize SDK
const loader = new SvidFileLoader(
'/tmp/agent-svid/svid.0.pem',
'/tmp/agent-svid/svid.0.key',
'/tmp/agent-svid/bundle.0.pem',
);
const svid = loader.load();
const client = new KahunaClient(
'https://kahuna-server.kahuna.svc.cluster.local:8443',
svid,
'spiffe://kahuna.local/server',
);
loader.watchForRotation((newSvid) => {
client.updateSvid(newSvid);
});
// Implement governed file write
const writeFileWithGovernance = governed({
client,
name: 'write_file',
// Map the call to what policy decides on: the SHAPE of the action, never its
// contents. Minimization is on by default, so only these projected values —
// plus a digest of the full parameters — leave the workload.
params: (i: { path: string; content: string }) => ({
action_type: 'write_data',
target_resource_type: 'file',
target_resource_id: i.path,
write_size_bytes: Buffer.byteLength(i.content, 'utf8'),
is_overwrite: existsSync(i.path),
}),
// Reached only after Kahuna authorizes. Throws on a refusal, so a denied call
// never looks to a caller — or a model — like one that ran.
execute: async (i) => {
await writeFile(i.path, i.content);
return `File written: ${i.path}`;
},
});Any agent framework
governed() returns a plain (input) => Promise<output>, which is the tool
interface every framework accepts. There is no adapter to install:
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';
const tool = new DynamicStructuredTool({
name: 'write_file',
description: 'Write content to a file.',
schema: z.object({ path: z.string(), content: z.string() }),
func: writeFileWithGovernance, // the governed() result from above
});The same value drops into other frameworks with one line each:
tool({ description, parameters: schema, execute: writeFileWithGovernance }); // Vercel AI SDK
FunctionTool.from(writeFileWithGovernance, { name, description, parameters }); // LlamaIndex
createTool({ id, description, inputSchema: schema,
execute: ({ context }) => writeFileWithGovernance(context) }); // Mastra
server.tool('write_file', schema, writeFileWithGovernance); // MCP
await writeFileWithGovernance({ path: '/tmp/report.txt', content: '...' }); // no frameworkFull per-framework wiring: framework-recipes.md.
A LangChain base class (
KahunaGovernedTool) also exists in this monorepo, but it is not published to npm — it predatesgoverned()and wraps exactly the snippet above. Use the snippet.
Governing several actions at once
governAll declares a whole toolset in one place, and makes an action you forgot
to declare fail rather than run ungoverned:
import { governAll } from '@aurite-ai/kahuna-sdk';
const governed = governAll({
client,
actions: {
write_file: { params: (i) => ({ /* ... */ }), execute: async (i) => '...' },
},
});
governed.assertCovers(tools.map((t) => t.name)); // at startup
// Error: 1 tool(s) registered with the agent but not governed: run_shell.It does not reduce the parameter mappings — those are what a security review reads, and they cannot be inferred. It removes the silent failure where a forgotten mapping gives the agent a capability nobody authorized.
Custom actions
The SDK has tiers compiled in for five action types. Everything your organization
registers in the Kahuna console — refund_order, provision_tenant, whatever the
agent actually does — is described by a definition that lives on the server, and
the client reads it at runtime.
That is the point: an admin registers an action in the console and your agents use it — no SDK release, and no code change per action.
Without a DefinitionCache, a custom action still authorizes, but it is sent with
no attributes: no policy about its fields can match, so it is refused and recorded
for an operator to see. With one, it is sent with exactly the fields the admin
classified as policy-relevant.
Nothing to wire. The client fetches the catalog over its own mTLS transport and refreshes when it goes stale:
import { KahunaClient } from '@aurite-ai/kahuna-sdk';
const kahuna = new KahunaClient(baseUrl, svid, serverSpiffeId);
// An action an operator registered in the console. No setup, no refresh loop.
await kahuna.authorize({
action_id: crypto.randomUUID(),
parameters: { action_type: 'refund_order', amount_minor: 4999 },
submitted_at: new Date().toISOString(),
});A definitions call happens only for an action this build has no tiers for, so a workload using only the built-in five never contacts the endpoint. If the endpoint is unreachable the action minimizes to nothing and is refused — the same path an unregistered action takes — rather than failing the call.
Pass your own cache to set a freshness bound, cap disclosure, or see refusals:
import { DefinitionCache, KahunaClient } from '@aurite-ai/kahuna-sdk';
const definitions: DefinitionCache = new DefinitionCache({
fetchDefinitions: (generation) => kahuna.fetchActionDefinitions(generation),
maxAgeMs: 5 * 60_000, // after this, unrefreshed means minimize to nothing
disclosureFloor: { refund_order: { customer_note: 'payload' } },
onRefused: (actionType, reason) => // never silent
console.warn(`definition refused: ${actionType} — ${reason}`),
});
const kahuna: KahunaClient = new KahunaClient(baseUrl, svid, serverSpiffeId, { definitions });Or { definitions: false } to switch it off: custom actions then minimize to
nothing, which is the pre-catalog behaviour.
const client = new KahunaClient(baseUrl, svid, serverSpiffeId, { definitions });
**Three rules worth knowing before you rely on it**, because the tier table is a
disclosure decision that now arrives from the server rather than from code you
reviewed:
1. **A fetched definition can never redefine a built-in action.** The server
refuses such a row and the client refuses it again, independently.
2. **A stale cache minimizes to nothing** — never to a previously fetched, wider
table. Definitions most often change because someone narrowed one.
3. **An unknown projection refuses the whole definition** rather than applying a
closest match.
If you want a ceiling the catalog cannot raise, pass a `disclosureFloor`. It can
only narrow — there is deliberately no way to express widening a field:
```typescript
new DefinitionCache({
fetchDefinitions,
// Whatever the catalog says, this workload never sends customer_email.
disclosureFloor: { refund_order: { customer_email: 'payload' } },
});Migration Guide
Before SDK (480 lines of integration overhead)
examples/kahuna-file-agent/
├── src/
│ ├── index.ts (113 lines)
│ ├── agent.ts (32 lines)
│ ├── tools/
│ │ └── write-file.ts (57 lines)
│ ├── svid-loader.ts (117 lines) ← DUPLICATED
│ ├── mtls-client.ts (76 lines) ← DUPLICATED
│ └── kahuna-client.ts (122 lines) ← DUPLICATEDAfter SDK (~55 lines)
examples/kahuna-file-agent/
├── src/
│ ├── index.ts (40 lines) ← Simplified
│ ├── agent.ts (15 lines) ← Simplified
│ └── tools/
│ └── write-file.ts (45 lines) ← Simplified
└── package.json
└── dependencies:
└── @aurite-ai/kahuna-sdk: ^0.1.0Migration steps:
- Install the SDK:
npm install @aurite-ai/kahuna-sdk - Replace local imports with SDK imports:
// Before import { SvidFileLoader } from './svid-loader.js'; import { KahunaClient } from './kahuna-client.js'; // After import { SvidFileLoader, KahunaClient } from '@aurite-ai/kahuna-sdk'; - Delete local infrastructure files:
src/svid-loader.tssrc/mtls-client.tssrc/kahuna-client.ts
- Update imports in your agent code
- Test that everything still works
Result: 87% reduction in code (429 → 55 lines)
Troubleshooting
SVID Load Errors
Problem: SvidLoadError: Failed to load SVID files after 5 attempts
Causes:
- SVID files don't exist at the specified paths
- Cert and key are mismatched (rotation race condition)
- File permissions prevent reading
Solutions:
- Verify the spiffe-helper sidecar is running
- Check file paths in environment variables
- Ensure the agent has read permissions on SVID files
- The loader retries automatically, but persistent failures indicate a configuration issue
SPIFFE ID Mismatch
Problem: SpiffeIdMismatchError: expected spiffe://kahuna.local/server, got spiffe://kahuna.local/other
Causes:
- Wrong server SPIFFE ID configured
- Connecting to wrong service
- Man-in-the-middle attack (rare in Kubernetes)
Solutions:
- Verify
KAHUNA_SERVER_SPIFFE_IDenvironment variable - Check that you're connecting to the correct Kahuna server URL
- Review SPIRE server configuration
Authorization Denied
Problem: outcome: 'denied' with deny_reason_code
Common reason codes:
credential_revoked- Agent's credential was revoked (check kill switches)policy_deny- No policy permits the actionrate_limit_exceeded- Too many requestsinvalid_parameters- Action parameters don't match schema
Solutions:
- Check Kahuna policies for the action type
- Verify action parameters match the expected schema
- Review agent kill switch status
- Check rate limits
Network Errors
Problem: Request failed: connect ECONNREFUSED
Causes:
- Kahuna server is not running
- Wrong URL configured
- Network connectivity issues
Solutions:
- Verify Kahuna server is running:
kubectl get pods -n kahuna - Check
KAHUNA_URLenvironment variable - Test connectivity:
curl -k https://kahuna-server.kahuna.svc.cluster.local:8443/health
Development
Building
npm run buildType Checking
npm run typecheckTesting
npm testCleaning
npm run cleanVersion History
See CHANGELOG.md for version history.
License
MIT — see LICENSE, which ships in the package.
Support
For issues or questions:
- Check the troubleshooting section
- Review example agents in
examples/ - Consult the Kahuna documentation
Related Documentation
- Kahuna Integration Overhead Analysis
- Kahuna Agent SDK Plan
- Example: File Agent
- Example: LangChain Agent
