Initial commit: Elydora Open Source — self-hosted audit platform for AI agents

Full self-hosted port of the Elydora tamper-evident audit protocol:
- Hono API server with PostgreSQL, Redis, MinIO adapters
- Ed25519 signed operations, chain hashing, Merkle epoch rollups
- PBKDF2 password hashing, HMAC-SHA256 JWT, 5-role RBAC
- Next.js management console
- Node.js, Python, and Go SDKs
- Docker Compose for local deployment
- Helm chart for Kubernetes
- Comprehensive security hardening and audit fixes applied

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-02 13:41:42 -05:00
1 parent 15547a8c35
commit e390e68594
207 files changed
+32790 -63

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
{
"version": 1,
"hooks": {
"preToolUse": [],
"postToolUse": []
}
}
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local
*.log
.DS_Store
Thumbs.db
+5
View File
@@ -0,0 +1,5 @@
src/
tsconfig.json
.gitignore
node_modules/
*.tsbuildinfo
+268
View File
@@ -0,0 +1,268 @@
# Elydora Node SDK
Official Node.js/TypeScript SDK for the [Elydora](https://elydora.com) tamper-evident audit platform. Build cryptographically verifiable audit trails for AI agent operations.
## Installation
```bash
npm install @elydora/sdk
```
Requires Node.js 18+ (uses built-in `crypto` module with Ed25519 support).
## Quick Start
```typescript
import { ElydoraClient } from '@elydora/sdk';
// Authenticate
const auth = await ElydoraClient.login('https://api.elydora.com', 'user@example.com', 'password');
// Create client
const client = new ElydoraClient({
orgId: auth.user.org_id,
agentId: 'my-agent-id',
privateKey: '<base64url-encoded-32-byte-ed25519-seed>',
baseUrl: 'https://api.elydora.com',
});
client.setToken(auth.token);
// Create and submit an operation
const eor = client.createOperation({
operationType: 'data.access',
subject: { user_id: 'u-123', resource: 'patient-record' },
action: { type: 'read', scope: 'full' },
payload: { record_id: 'rec-456' },
});
const { receipt } = await client.submitOperation(eor);
console.log('Operation submitted:', receipt.operation_id);
```
## CLI
The SDK includes a CLI for installing audit hooks into AI coding agents.
```bash
npx elydora install \
--agent claudecode \
--org_id org-123 \
--agent_id agent-456 \
--private_key <key> \
--kid agent-456-key-v1
```
### Commands
| Command | Description |
|---------|-------------|
| `elydora install` | Install Elydora audit hook for a coding agent |
| `elydora uninstall` | Remove Elydora audit hook for a coding agent |
| `elydora status` | Show installation status for all agents |
| `elydora agents` | List supported coding agents |
### Supported Agents
| Agent | Key |
|-------|-----|
| Claude Code | `claudecode` |
| Cursor | `cursor` |
| Gemini CLI | `gemini` |
| Kiro CLI | `kirocli` |
| Kiro IDE | `kiroide` |
| OpenCode | `opencode` |
| Copilot CLI | `copilot` |
| Letta Code | `letta` |
## API Reference
### Configuration
```typescript
const client = new ElydoraClient({
orgId: string, // Organization ID
agentId: string, // Agent ID
privateKey: string, // Base64url-encoded Ed25519 seed
baseUrl?: string, // API base URL (default: https://api.elydora.com)
ttlMs?: number, // Operation TTL in ms (default: 30000)
maxRetries?: number, // Max retries on transient failures (default: 3)
kid?: string, // Key ID (default: {agentId}-key-v1)
});
```
### Authentication
```typescript
// Register a new user and organization
const reg = await ElydoraClient.register(baseUrl, email, password, displayName?, orgName?);
// Login and receive a JWT
const auth = await ElydoraClient.login(baseUrl, email, password);
// Set token on client
client.setToken(auth.token);
// Get current authenticated user profile
const { user } = await client.getMe();
// Issue a new API token (with optional TTL in seconds)
const { token, expires_at } = await client.issueApiToken(3600);
```
### Operations
```typescript
// Create a signed EOR locally (synchronous, no network call)
const eor = client.createOperation({
operationType: 'inference',
subject: { model: 'gpt-4' },
action: { type: 'completion' },
payload: { prompt: 'Hello' },
});
// Submit to API
const { receipt } = await client.submitOperation(eor);
// Retrieve an operation
const op = await client.getOperation(operationId);
// Verify integrity (signature, chain, receipt, merkle)
const result = await client.verifyOperation(operationId);
```
### Agent Management
```typescript
// Register a new agent
const agent = await client.registerAgent({
agent_id: 'my-agent',
display_name: 'My Agent',
responsible_entity: 'team@example.com',
keys: [{ kid: 'key-v1', public_key: '<base64url>', algorithm: 'ed25519' }],
});
// Get agent details
const details = await client.getAgent(agentId);
// Freeze an agent
await client.freezeAgent(agentId, 'security review');
// Revoke a key
await client.revokeKey(agentId, kid, 'key rotation');
// List all agents in the organization
const { agents } = await client.listAgents();
// Unfreeze a previously frozen agent
await client.unfreezeAgent(agentId, 'review complete');
// Delete an agent permanently
const { deleted } = await client.deleteAgent(agentId);
```
### Audit
```typescript
const results = await client.queryAudit({
agent_id: 'agent-123',
operation_type: 'inference',
start_time: Date.now() - 86400000,
end_time: Date.now(),
limit: 50,
});
```
### Epochs
```typescript
const { epochs } = await client.listEpochs();
const { epoch } = await client.getEpoch(epochId);
```
### Exports
```typescript
const { export: exp } = await client.createExport({
start_time: Date.now() - 86400000,
end_time: Date.now(),
format: 'json',
});
const { exports } = await client.listExports();
const { export: detail, download_url } = await client.getExport(exportId);
// Download export file data
const data = await client.downloadExport(exportId);
```
### JWKS
```typescript
const { keys } = await client.getJWKS();
```
### Health
```typescript
// Check API health (no authentication required)
const health = await client.health();
// health.status, health.version, health.protocol_version, health.timestamp
```
### Client State
```typescript
// Get the current chain hash (useful for debugging/inspection)
const chainHash = client.getChainHash();
// Get the Ed25519 public key derived from the configured private key
const publicKey = client.getPublicKey();
```
### Crypto Utilities
The SDK exports low-level cryptographic primitives for advanced use:
```typescript
import {
jcsCanonicalise, // RFC 8785 JSON Canonicalization
sha256Base64url, // SHA-256 hash as base64url
computeChainHash, // Chain hash computation
computePayloadHash, // Payload hash computation
signEd25519, // Ed25519 signing
derivePublicKey, // Derive public key from private seed
ZERO_CHAIN_HASH, // Genesis chain hash constant
} from '@elydora/sdk';
```
### Utility Functions
```typescript
import {
uuidv7, // Generate a UUIDv7 (time-ordered, RFC 9562)
generateNonce, // Generate a 16-byte random nonce (base64url)
base64urlEncode, // Encode Buffer/Uint8Array to base64url (no padding)
base64urlDecode, // Decode base64url string to Buffer
} from '@elydora/sdk';
```
## Error Handling
```typescript
import { ElydoraError } from '@elydora/sdk';
try {
await client.submitOperation(eor);
} catch (err) {
if (err instanceof ElydoraError) {
console.error(err.code); // e.g. 'INVALID_SIGNATURE'
console.error(err.message); // Human-readable message
console.error(err.statusCode); // HTTP status code
console.error(err.requestId); // Request ID for support
}
}
```
## License
MIT
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@elydora/sdk",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@elydora/sdk",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@types/node": {
"version": "22.19.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
"integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@elydora/sdk",
"version": "1.1.0",
"description": "Node.js/TypeScript SDK for the Elydora tamper-evident audit platform",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"elydora": "dist/cli.js"
},
"files": [
"dist",
"scripts"
],
"scripts": {
"build": "tsc && node scripts/postbuild.mjs",
"prepublishOnly": "npm run build"
},
"engines": {
"node": ">=18.0.0"
},
"license": "MIT",
"devDependencies": {
"typescript": "^5.7.0",
"@types/node": "^22.0.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
import { readFile, writeFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const cliPath = resolve(__dirname, '..', 'dist', 'cli.js');
const content = await readFile(cliPath, 'utf-8');
if (!content.startsWith('#!/')) {
await writeFile(cliPath, '#!/usr/bin/env node\n' + content, 'utf-8');
}
+325
View File
@@ -0,0 +1,325 @@
import { parseArgs } from 'node:util';
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { derivePublicKey } from './crypto.js';
import { SUPPORTED_AGENTS } from './plugins/registry.js';
import type { AgentPlugin, InstallConfig } from './plugins/base.js';
import { generateHookScript, generateGuardScript } from './plugins/hook-template.js';
import { claudecodePlugin } from './plugins/claudecode.js';
import { cursorPlugin } from './plugins/cursor.js';
import { geminiPlugin } from './plugins/gemini.js';
import { kirocliPlugin } from './plugins/kirocli.js';
import { kiroidePlugin } from './plugins/kiroide.js';
import { opencodePlugin } from './plugins/opencode.js';
import { copilotPlugin } from './plugins/copilot.js';
import { lettaPlugin } from './plugins/letta.js';
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
const PLUGINS: ReadonlyMap<string, AgentPlugin> = new Map([
['claudecode', claudecodePlugin],
['cursor', cursorPlugin],
['gemini', geminiPlugin],
['kirocli', kirocliPlugin],
['kiroide', kiroidePlugin],
['opencode', opencodePlugin],
['copilot', copilotPlugin],
['letta', lettaPlugin],
]);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function die(message: string): never {
console.error(`Error: ${message}`);
process.exit(1);
}
function printUsage(): void {
console.log(`Elydora CLI — Tamper-evident audit for AI coding agents
Usage:
elydora install --agent <name> --org_id <id> --agent_id <id> --private_key <key> --kid <kid> [--token <jwt>] [--base_url <url>]
elydora uninstall --agent <name> [--agent_id <id>]
elydora status
elydora agents
Commands:
install Install Elydora audit hook for a coding agent
uninstall Remove Elydora audit hook for a coding agent
status Show installation status for all agents
agents List supported coding agents
Supported agents: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}
`);
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
async function cmdInstall(args: string[]): Promise<void> {
const { values } = parseArgs({
args,
options: {
agent: { type: 'string' },
org_id: { type: 'string' },
agent_id: { type: 'string' },
private_key: { type: 'string' },
kid: { type: 'string' },
token: { type: 'string' },
base_url: { type: 'string' },
},
strict: true,
});
const agentName = values.agent;
if (!agentName) die('--agent is required');
if (!SUPPORTED_AGENTS.has(agentName)) {
die(`Unknown agent "${agentName}". Supported: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}`);
}
const orgId = values.org_id;
if (!orgId) die('--org_id is required');
const agentId = values.agent_id;
if (!agentId) die('--agent_id is required');
const privateKey = values.private_key;
if (!privateKey) die('--private_key is required');
const kid = values.kid;
if (!kid) die('--kid is required');
const token = values.token;
const baseUrl = values.base_url ?? 'https://api.elydora.com';
// Validate private key by deriving public key
let publicKey: string;
try {
publicKey = derivePublicKey(privateKey);
} catch {
die('Invalid private key — could not derive public key');
}
console.log(`Verifying private key... Public key: ${publicKey.slice(0, 12)}...`);
// Create ~/.elydora/{agentId}/ directory
const agentDir = path.join(ELYDORA_DIR, agentId);
await fsp.mkdir(agentDir, { recursive: true });
// Write agent config
const agentConfigPath = path.join(agentDir, 'config.json');
const agentConfig = {
org_id: orgId,
agent_id: agentId,
kid,
base_url: baseUrl,
...(token ? { token } : {}),
agent_name: agentName,
};
await fsp.writeFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n', 'utf-8');
if (process.platform !== 'win32') {
await fsp.chmod(agentConfigPath, 0o600);
}
console.log(` Agent config: ${agentConfigPath}`);
// Write private key (chmod 600)
const keyPath = path.join(agentDir, 'private.key');
await fsp.writeFile(keyPath, privateKey, { encoding: 'utf-8', mode: 0o600 });
if (process.platform !== 'win32') {
await fsp.chmod(keyPath, 0o600);
}
console.log(` Private key: ${keyPath}`);
// Generate and write hook script (PostToolUse — audit logging)
const hookScriptPath = path.join(agentDir, 'hook.js');
const hookScript = generateHookScript(agentName, agentId);
await fsp.writeFile(hookScriptPath, hookScript, { encoding: 'utf-8', mode: 0o755 });
console.log(` Hook script: ${hookScriptPath}`);
// Generate and write guard script (PreToolUse — freeze enforcement)
const guardScriptPath = path.join(agentDir, 'guard.js');
const guardScript = generateGuardScript(agentName, agentId);
await fsp.writeFile(guardScriptPath, guardScript, { encoding: 'utf-8', mode: 0o755 });
console.log(` Guard script: ${guardScriptPath}`);
// Install agent-specific config hook
const plugin = PLUGINS.get(agentName)!;
const installConfig: InstallConfig = {
agentName,
orgId,
agentId,
privateKey,
kid,
token,
baseUrl,
hookScriptPath,
guardScriptPath,
};
await plugin.install(installConfig);
const entry = SUPPORTED_AGENTS.get(agentName)!;
console.log(`\nElydora audit hook installed for ${entry.name}.`);
}
async function cmdUninstall(args: string[]): Promise<void> {
const { values } = parseArgs({
args,
options: {
agent: { type: 'string' },
agent_id: { type: 'string' },
},
strict: true,
});
const agentName = values.agent;
if (!agentName) die('--agent is required');
if (!SUPPORTED_AGENTS.has(agentName)) {
die(`Unknown agent "${agentName}". Supported: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}`);
}
let agentId = values.agent_id;
// If --agent_id not provided, scan ~/.elydora/*/config.json for matching agent_name
if (!agentId) {
try {
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json');
try {
const raw = await fsp.readFile(cfgPath, 'utf-8');
const cfg = JSON.parse(raw);
if (cfg.agent_name === agentName) {
agentId = entry.name;
break;
}
} catch {
// Skip unreadable configs
}
}
} catch {
// ELYDORA_DIR may not exist
}
if (!agentId) {
die(`No installed agent found for "${agentName}". Use --agent_id to specify explicitly.`);
}
}
const plugin = PLUGINS.get(agentName)!;
const registryEntry = SUPPORTED_AGENTS.get(agentName)!;
// Uninstall agent-specific config
await plugin.uninstall(agentId);
// Remove entire agent directory
const agentDir = path.join(ELYDORA_DIR, agentId);
try {
await fsp.rm(agentDir, { recursive: true, force: true });
} catch {
// Already removed
}
console.log(`Elydora audit hook uninstalled for ${registryEntry.name}.`);
}
async function cmdStatus(): Promise<void> {
console.log('Elydora Agent Status\n');
let anyInstalled = false;
// Scan ~/.elydora/*/config.json to discover installed agents
const installedAgents: Array<{ agentId: string; agentName: string; configPath: string }> = [];
try {
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json');
try {
const raw = await fsp.readFile(cfgPath, 'utf-8');
const cfg = JSON.parse(raw);
if (cfg.agent_name && cfg.agent_id) {
installedAgents.push({ agentId: entry.name, agentName: cfg.agent_name, configPath: cfgPath });
}
} catch {
// Skip unreadable configs
}
}
} catch {
// ELYDORA_DIR may not exist
}
for (const [name, plugin] of PLUGINS) {
const st = await plugin.status();
const statusIcon = st.installed ? '[installed]' : '[not installed]';
// Find matching installed agent(s) for this plugin
const matching = installedAgents.filter((a) => a.agentName === name);
console.log(` ${st.displayName} (${name}) ${statusIcon}`);
if (st.installed || st.hookConfigured || st.hookScriptExists) {
console.log(` Hook config: ${st.hookConfigured ? 'yes' : 'no'}`);
console.log(` Hook script: ${st.hookScriptExists ? 'yes' : 'no'}`);
console.log(` Config path: ${st.configPath}`);
for (const m of matching) {
console.log(` Agent ID: ${m.agentId}`);
}
}
if (st.installed) anyInstalled = true;
}
if (!anyInstalled) {
console.log('\nNo agents installed. Run "elydora install --agent <name>" to get started.');
}
}
function cmdAgents(): void {
console.log('Supported Coding Agents:\n');
for (const [key, entry] of SUPPORTED_AGENTS) {
console.log(` ${key.padEnd(12)} ${entry.name}`);
}
console.log('\nUse "elydora install --agent <name>" to install an audit hook.');
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
printUsage();
process.exit(0);
}
const command = args[0];
const commandArgs = args.slice(1);
switch (command) {
case 'install':
await cmdInstall(commandArgs);
break;
case 'uninstall':
await cmdUninstall(commandArgs);
break;
case 'status':
await cmdStatus();
break;
case 'agents':
cmdAgents();
break;
default:
die(`Unknown command "${command}". Run "elydora --help" for usage.`);
}
}
main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+428
View File
@@ -0,0 +1,428 @@
import type {
ElydoraClientConfig,
CreateOperationParams,
EOR,
RegisterAgentRequest,
RegisterAgentResponse,
GetAgentResponse,
ListAgentsResponse,
UnfreezeAgentResponse,
DeleteAgentResponse,
SubmitOperationResponse,
GetOperationResponse,
VerifyOperationResponse,
AuditQueryRequest,
AuditQueryResponse,
ListEpochsResponse,
GetEpochResponse,
CreateExportRequest,
CreateExportResponse,
ListExportsResponse,
GetExportResponse,
GetMeResponse,
IssueApiTokenResponse,
JWKSResponse,
HealthResponse,
AuthRegisterResponse,
AuthLoginResponse,
ErrorResponse,
} from './types.js';
import { ElydoraError } from './errors.js';
import {
jcsCanonicalise,
computePayloadHash,
computeChainHash,
signEd25519,
derivePublicKey,
ZERO_CHAIN_HASH,
} from './crypto.js';
import { uuidv7, generateNonce } from './utils.js';
const DEFAULT_BASE_URL = 'https://api.elydora.com';
const DEFAULT_TTL_MS = 30_000;
const DEFAULT_MAX_RETRIES = 3;
export class ElydoraClient {
private readonly orgId: string;
private readonly agentId: string;
private readonly privateKey: string;
private readonly baseUrl: string;
private readonly ttlMs: number;
private readonly maxRetries: number;
private readonly kid: string;
private prevChainHash: string;
private token: string | undefined;
constructor(config: ElydoraClientConfig) {
this.orgId = config.orgId;
this.agentId = config.agentId;
this.privateKey = config.privateKey;
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
this.kid = config.kid ?? this.agentId + '-key-v1';
this.prevChainHash = ZERO_CHAIN_HASH;
}
/**
* Set the JWT token used for authenticated API calls.
*/
setToken(token: string): void {
this.token = token;
}
/**
* Get the current chain hash (useful for debugging/inspection).
*/
getChainHash(): string {
return this.prevChainHash;
}
/**
* Get the public key derived from the configured private key.
*/
getPublicKey(): string {
return derivePublicKey(this.privateKey);
}
// -------------------------------------------------------------------------
// Auth (static methods — no instance needed)
// -------------------------------------------------------------------------
static async register(
baseUrl: string,
email: string,
password: string,
displayName?: string,
orgName?: string,
): Promise<AuthRegisterResponse> {
const url = `${baseUrl.replace(/\/+$/, '')}/v1/auth/register`;
const body: Record<string, unknown> = { email, password };
if (displayName !== undefined) body.display_name = displayName;
if (orgName !== undefined) body.org_name = orgName;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return handleResponse<AuthRegisterResponse>(res);
}
static async login(
baseUrl: string,
email: string,
password: string,
): Promise<AuthLoginResponse> {
const url = `${baseUrl.replace(/\/+$/, '')}/v1/auth/login`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
return handleResponse<AuthLoginResponse>(res);
}
// -------------------------------------------------------------------------
// Auth (instance methods — require token)
// -------------------------------------------------------------------------
async getMe(): Promise<GetMeResponse> {
return this.request<GetMeResponse>('GET', '/v1/auth/me');
}
async issueApiToken(ttlSeconds?: number | null): Promise<IssueApiTokenResponse> {
return this.request<IssueApiTokenResponse>('POST', '/v1/auth/token', { ttl_seconds: ttlSeconds ?? null });
}
// -------------------------------------------------------------------------
// Agent management
// -------------------------------------------------------------------------
async registerAgent(request: RegisterAgentRequest): Promise<RegisterAgentResponse> {
return this.request<RegisterAgentResponse>('POST', '/v1/agents/register', request);
}
async getAgent(agentId: string): Promise<GetAgentResponse> {
return this.request<GetAgentResponse>('GET', `/v1/agents/${encodeURIComponent(agentId)}`);
}
async freezeAgent(agentId: string, reason: string): Promise<void> {
await this.request<unknown>('POST', `/v1/agents/${encodeURIComponent(agentId)}/freeze`, { reason });
}
async listAgents(): Promise<ListAgentsResponse> {
return this.request<ListAgentsResponse>('GET', '/v1/agents');
}
async unfreezeAgent(agentId: string, reason: string): Promise<UnfreezeAgentResponse> {
return this.request<UnfreezeAgentResponse>('POST', `/v1/agents/${encodeURIComponent(agentId)}/unfreeze`, { reason });
}
async deleteAgent(agentId: string): Promise<DeleteAgentResponse> {
return this.request<DeleteAgentResponse>('DELETE', `/v1/agents/${encodeURIComponent(agentId)}`);
}
async revokeKey(agentId: string, kid: string, reason: string): Promise<void> {
await this.request<unknown>('POST', `/v1/agents/${encodeURIComponent(agentId)}/revoke`, { kid, reason });
}
// -------------------------------------------------------------------------
// Operations (core)
// -------------------------------------------------------------------------
/**
* Construct and sign an Elydora Operation Record (EOR) locally.
*
* 1. Generate UUIDv7 for operation_id
* 2. Generate random nonce (16 bytes, base64url)
* 3. Compute payload_hash (SHA-256 of JCS-canonicalized payload)
* 4. Compute chain_hash from prev_chain_hash
* 5. Construct canonical EOR (minus signature)
* 6. Sign JCS-canonicalized EOR with Ed25519
* 7. Update internal prev_chain_hash
*/
createOperation(params: CreateOperationParams): EOR {
const operationId = uuidv7();
const issuedAt = Date.now();
const nonce = generateNonce();
const payload = params.payload ?? null;
// Compute payload hash
const payloadHash = computePayloadHash(payload);
// Compute chain hash
const chainHash = computeChainHash(
this.prevChainHash,
payloadHash,
operationId,
issuedAt,
);
// Construct the EOR without signature for signing
const eorWithoutSig: Omit<EOR, 'signature'> = {
op_version: '1.0',
operation_id: operationId,
org_id: this.orgId,
agent_id: this.agentId,
issued_at: issuedAt,
ttl_ms: this.ttlMs,
nonce,
operation_type: params.operationType,
subject: params.subject,
action: params.action,
payload,
payload_hash: payloadHash,
prev_chain_hash: this.prevChainHash,
agent_pubkey_kid: this.kid,
};
// Sign the JCS-canonicalized EOR (without signature field)
const canonical = jcsCanonicalise(eorWithoutSig);
const signature = signEd25519(this.privateKey, Buffer.from(canonical, 'utf-8'));
// Update internal chain state
this.prevChainHash = chainHash;
return {
...eorWithoutSig,
signature,
};
}
async submitOperation(eor: EOR): Promise<SubmitOperationResponse> {
return this.request<SubmitOperationResponse>('POST', '/v1/operations', eor);
}
async getOperation(operationId: string): Promise<GetOperationResponse> {
return this.request<GetOperationResponse>('GET', `/v1/operations/${encodeURIComponent(operationId)}`);
}
async verifyOperation(operationId: string): Promise<VerifyOperationResponse> {
return this.request<VerifyOperationResponse>('POST', `/v1/operations/${encodeURIComponent(operationId)}/verify`);
}
// -------------------------------------------------------------------------
// Audit
// -------------------------------------------------------------------------
async queryAudit(params: AuditQueryRequest): Promise<AuditQueryResponse> {
return this.request<AuditQueryResponse>('POST', '/v1/audit/query', params);
}
// -------------------------------------------------------------------------
// Epochs
// -------------------------------------------------------------------------
async listEpochs(): Promise<ListEpochsResponse> {
return this.request<ListEpochsResponse>('GET', '/v1/epochs');
}
async getEpoch(epochId: string): Promise<GetEpochResponse> {
return this.request<GetEpochResponse>('GET', `/v1/epochs/${encodeURIComponent(epochId)}`);
}
// -------------------------------------------------------------------------
// Exports
// -------------------------------------------------------------------------
async createExport(params: CreateExportRequest): Promise<CreateExportResponse> {
return this.request<CreateExportResponse>('POST', '/v1/exports', params);
}
async listExports(): Promise<ListExportsResponse> {
return this.request<ListExportsResponse>('GET', '/v1/exports');
}
async getExport(exportId: string): Promise<GetExportResponse> {
return this.request<GetExportResponse>('GET', `/v1/exports/${encodeURIComponent(exportId)}`);
}
async downloadExport(exportId: string): Promise<ArrayBuffer> {
const url = `${this.baseUrl}/v1/exports/${encodeURIComponent(exportId)}/download`;
const headers: Record<string, string> = {};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const res = await fetch(url, { method: 'GET', headers });
if (!res.ok) {
return handleResponse<never>(res);
}
return res.arrayBuffer();
}
// -------------------------------------------------------------------------
// JWKS
// -------------------------------------------------------------------------
async getJWKS(): Promise<JWKSResponse> {
const url = `${this.baseUrl}/.well-known/elydora/jwks.json`;
const res = await fetch(url, {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
return handleResponse<JWKSResponse>(res);
}
// -------------------------------------------------------------------------
// Health
// -------------------------------------------------------------------------
async health(): Promise<HealthResponse> {
const url = `${this.baseUrl}/v1/health`;
const res = await fetch(url, {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
return handleResponse<HealthResponse>(res);
}
// -------------------------------------------------------------------------
// Internal HTTP helpers
// -------------------------------------------------------------------------
private async request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Accept': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
let lastError: Error | undefined;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
// Only retry on 429 or 5xx
if (attempt < this.maxRetries && (res.status === 429 || res.status >= 500)) {
const retryAfter = res.headers.get('Retry-After');
const delayMs = retryAfter
? parseInt(retryAfter, 10) * 1000
: Math.min(1000 * 2 ** attempt, 10_000);
await sleep(delayMs);
continue;
}
return await handleResponse<T>(res);
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
// Only retry on network errors, not API errors
if (err instanceof ElydoraError) {
throw err;
}
if (attempt < this.maxRetries) {
await sleep(Math.min(1000 * 2 ** attempt, 10_000));
continue;
}
}
}
throw lastError ?? new Error('Request failed after retries');
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function handleResponse<T>(res: Response): Promise<T> {
if (res.ok) {
// 204 No Content
if (res.status === 204) {
return undefined as T;
}
return (await res.json()) as T;
}
let errorBody: ErrorResponse | undefined;
try {
errorBody = (await res.json()) as ErrorResponse;
} catch {
// Response body was not valid JSON
}
if (errorBody?.error) {
throw new ElydoraError(
res.status,
errorBody.error.code,
errorBody.error.message,
errorBody.error.request_id,
errorBody.error.details,
);
}
throw new ElydoraError(
res.status,
'INTERNAL_ERROR',
`HTTP ${res.status}: ${res.statusText}`,
'unknown',
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+157
View File
@@ -0,0 +1,157 @@
import crypto from 'node:crypto';
import { base64urlEncode } from './utils.js';
// ---------------------------------------------------------------------------
// JCS Canonicalization (RFC 8785)
// ---------------------------------------------------------------------------
/**
* Canonicalize a JSON value according to JCS (RFC 8785).
*
* - Object keys sorted lexicographically by UTF-16 code units
* - No whitespace
* - Numbers serialized using ES2015 Number serialization
* - Strings serialized with minimal escaping per JSON spec
*/
export function jcsCanonicalise(value: unknown): string {
if (value === null || value === undefined) {
return 'null';
}
if (typeof value === 'boolean' || typeof value === 'number') {
return JSON.stringify(value);
}
if (typeof value === 'string') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
const elements = value.map((v) => jcsCanonicalise(v));
return '[' + elements.join(',') + ']';
}
if (typeof value === 'object') {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
const pairs: string[] = [];
for (const key of keys) {
if (obj[key] !== undefined) {
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(obj[key]));
}
}
return '{' + pairs.join(',') + '}';
}
return JSON.stringify(value);
}
// ---------------------------------------------------------------------------
// SHA-256
// ---------------------------------------------------------------------------
/**
* Compute SHA-256 of a UTF-8 string or buffer and return base64url.
*/
export function sha256Base64url(data: string | Buffer): string {
const input = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
const hash = crypto.createHash('sha256').update(input).digest();
return base64urlEncode(hash);
}
// ---------------------------------------------------------------------------
// Chain hash computation
// ---------------------------------------------------------------------------
/**
* Compute the chain hash for an operation.
*
* chain_hash = SHA-256(prev_chain_hash + "|" + payload_hash + "|" + operation_id + "|" + issued_at)
*
* All inputs concatenated as UTF-8 strings separated by '|'.
*/
export function computeChainHash(
prevChainHash: string,
payloadHash: string,
operationId: string,
issuedAt: number,
): string {
const input = `${prevChainHash}|${payloadHash}|${operationId}|${issuedAt}`;
return sha256Base64url(input);
}
// ---------------------------------------------------------------------------
// Payload hash
// ---------------------------------------------------------------------------
/**
* Compute SHA-256 hash of JCS-canonicalized payload, returned as base64url.
*/
export function computePayloadHash(
payload: Record<string, unknown> | string | null,
): string {
const canonical = jcsCanonicalise(payload);
return sha256Base64url(canonical);
}
// ---------------------------------------------------------------------------
// Ed25519 signing
// ---------------------------------------------------------------------------
// PKCS8 prefix for wrapping a 32-byte Ed25519 seed
const PKCS8_ED25519_PREFIX = Buffer.from([
0x30, 0x2e, // SEQUENCE (46 bytes)
0x02, 0x01, 0x00, // INTEGER 0
0x30, 0x05, // SEQUENCE (5 bytes)
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519)
0x04, 0x22, // OCTET STRING (34 bytes)
0x04, 0x20, // OCTET STRING (32 bytes)
]);
/**
* Import a base64url-encoded 32-byte Ed25519 seed as a Node.js KeyObject.
*/
function importPrivateKey(privateKeyBase64url: string): crypto.KeyObject {
const seed = Buffer.from(privateKeyBase64url, 'base64url');
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
return crypto.createPrivateKey({
key: pkcs8,
format: 'der',
type: 'pkcs8',
});
}
/**
* Sign data with Ed25519 using a base64url-encoded 32-byte seed.
*
* @returns base64url-encoded 64-byte signature
*/
export function signEd25519(privateKeyBase64url: string, data: Buffer): string {
const keyObject = importPrivateKey(privateKeyBase64url);
const signature = crypto.sign(null, data, keyObject);
return base64urlEncode(signature);
}
/**
* Derive the Ed25519 public key from a base64url-encoded 32-byte seed.
*
* @returns base64url-encoded 32-byte public key
*/
export function derivePublicKey(privateKeyBase64url: string): string {
const keyObject = importPrivateKey(privateKeyBase64url);
const publicKeyObject = crypto.createPublicKey(keyObject);
const rawPublicKey = publicKeyObject.export({ type: 'spki', format: 'der' });
// The raw 32-byte public key is the last 32 bytes of the SPKI DER encoding
const publicKeyBytes = rawPublicKey.subarray(rawPublicKey.length - 32);
return base64urlEncode(publicKeyBytes);
}
// ---------------------------------------------------------------------------
// Zero chain hash (initial prev_chain_hash for the first operation)
// ---------------------------------------------------------------------------
/**
* The initial chain hash value: base64url encoding of 32 zero bytes.
* Must match the backend's GENESIS_CHAIN_HASH constant exactly.
*/
export const ZERO_CHAIN_HASH: string = base64urlEncode(Buffer.alloc(32, 0));
+23
View File
@@ -0,0 +1,23 @@
import type { ErrorCode } from './types.js';
export class ElydoraError extends Error {
public readonly code: ErrorCode;
public readonly requestId: string;
public readonly statusCode: number;
public readonly details?: Record<string, unknown>;
constructor(
statusCode: number,
code: ErrorCode,
message: string,
requestId: string,
details?: Record<string, unknown>,
) {
super(message);
this.name = 'ElydoraError';
this.statusCode = statusCode;
this.code = code;
this.requestId = requestId;
this.details = details;
}
}
+71
View File
@@ -0,0 +1,71 @@
export { ElydoraClient } from './client.js';
export { ElydoraError } from './errors.js';
export {
jcsCanonicalise,
sha256Base64url,
computeChainHash,
computePayloadHash,
signEd25519,
derivePublicKey,
ZERO_CHAIN_HASH,
} from './crypto.js';
export {
uuidv7,
generateNonce,
base64urlEncode,
base64urlDecode,
} from './utils.js';
export type {
// Enums
AgentStatus,
KeyStatus,
ExportStatus,
RbacRole,
ErrorCode,
// Entities
Agent,
AgentKey,
Operation,
Receipt,
Epoch,
Organization,
User,
Export,
// Protocol
EOR,
EAR,
// API request/response
ElydoraClientConfig,
CreateOperationParams,
RegisterAgentRequest,
RegisterAgentResponse,
GetAgentResponse,
ListAgentsResponse,
UnfreezeAgentResponse,
DeleteAgentResponse,
SubmitOperationResponse,
GetOperationResponse,
VerifyOperationResponse,
AuditQueryRequest,
AuditQueryResponse,
GetEpochResponse,
ListEpochsResponse,
CreateExportRequest,
CreateExportResponse,
GetExportResponse,
ListExportsResponse,
GetMeResponse,
IssueApiTokenResponse,
JWK,
JWKSResponse,
HealthResponse,
AuthRegisterResponse,
AuthLoginResponse,
ErrorResponse,
} from './types.js';
+26
View File
@@ -0,0 +1,26 @@
export interface InstallConfig {
readonly agentName: string;
readonly orgId: string;
readonly agentId: string;
readonly privateKey: string;
readonly kid: string;
readonly token?: string;
readonly baseUrl: string;
readonly hookScriptPath: string;
readonly guardScriptPath: string;
}
export interface PluginStatus {
readonly installed: boolean;
readonly agentName: string;
readonly displayName: string;
readonly hookConfigured: boolean;
readonly hookScriptExists: boolean;
readonly configPath: string;
}
export interface AgentPlugin {
install(config: InstallConfig): Promise<void>;
uninstall(agentId?: string): Promise<void>;
status(): Promise<PluginStatus>;
}
+171
View File
@@ -0,0 +1,171 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'claudecode';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
return entry.configDir.replace(/^~/, os.homedir());
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function buildHookCommand(scriptPath: string): string {
return `node "${scriptPath}"`;
}
function isElydoraCommand(cmd: string, agentId?: string): boolean {
if (!cmd.includes('elydora')) return false;
if (agentId) {
return cmd.includes(agentId);
}
return true;
}
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
return arr.filter((entry) => {
if (Array.isArray(entry.hooks)) {
const cmds = entry.hooks as Array<Record<string, unknown>>;
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
}
if (typeof entry.command === 'string') {
return !isElydoraCommand(entry.command, agentId);
}
return true;
});
}
export const claudecodePlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath();
let settings: Record<string, unknown> = {};
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
// File doesn't exist or isn't valid JSON — start fresh
}
if (!settings.hooks || typeof settings.hooks !== 'object') {
settings.hooks = {};
}
const hooks = settings.hooks as Record<string, unknown>;
// --- PreToolUse (guard — freeze enforcement) ---
if (!Array.isArray(hooks.PreToolUse)) {
hooks.PreToolUse = [];
}
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
preFiltered.push({
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
});
hooks.PreToolUse = preFiltered;
// --- PostToolUse (audit logging) ---
if (!Array.isArray(hooks.PostToolUse)) {
hooks.PostToolUse = [];
}
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
postFiltered.push({
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
});
hooks.PostToolUse = postFiltered;
settings.hooks = hooks;
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
let settings: Record<string, unknown>;
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
return; // Nothing to uninstall
}
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (!hooks) return;
if (Array.isArray(hooks.PreToolUse)) {
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
}
if (Array.isArray(hooks.PostToolUse)) {
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
}
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const settings = JSON.parse(raw);
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (hooks) {
const checkArr = (arr: unknown) => {
if (!Array.isArray(arr)) return false;
return (arr as Array<Record<string, unknown>>).some((entry) => {
if (Array.isArray(entry.hooks)) {
return (entry.hooks as Array<Record<string, unknown>>).some(
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
);
}
return typeof entry.command === 'string' && entry.command.includes('elydora');
});
};
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
// Extract hook script path from PostToolUse command
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
if (Array.isArray(e.hooks)) {
for (const h of e.hooks as Array<Record<string, unknown>>) {
const cmd = h.command as string;
if (cmd && cmd.includes('elydora')) {
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
}
}
}
}
}
}
} catch {
// Config not readable
}
let hookScriptExists = false;
if (hookScriptPath) {
try {
await fsp.access(hookScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
};
},
};
+164
View File
@@ -0,0 +1,164 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'copilot';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
return path.join(process.cwd(), entry.configDir);
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function buildHookCommand(scriptPath: string): string {
return `node "${scriptPath}"`;
}
function isElydoraCommand(cmd: string, agentId?: string): boolean {
if (!cmd.includes('elydora')) return false;
if (agentId) {
return cmd.includes(agentId);
}
return true;
}
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
return arr.filter((h) => {
if (typeof h.bash === 'string' && isElydoraCommand(h.bash, agentId)) return false;
if (typeof h.powershell === 'string' && isElydoraCommand(h.powershell, agentId)) return false;
return true;
});
}
export const copilotPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath();
let settings: Record<string, unknown> = { version: 1 };
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
// Start fresh
}
settings.version = 1;
if (!settings.hooks || typeof settings.hooks !== 'object') {
settings.hooks = {};
}
const hooks = settings.hooks as Record<string, unknown>;
// --- preToolUse (guard — freeze enforcement) ---
if (!Array.isArray(hooks.preToolUse)) {
hooks.preToolUse = [];
}
const preFiltered = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
preFiltered.push({
type: 'command',
bash: buildHookCommand(config.guardScriptPath),
powershell: buildHookCommand(config.guardScriptPath),
timeoutSec: 5,
});
hooks.preToolUse = preFiltered;
// --- postToolUse (audit logging) ---
if (!Array.isArray(hooks.postToolUse)) {
hooks.postToolUse = [];
}
const postFiltered = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
postFiltered.push({
type: 'command',
bash: buildHookCommand(config.hookScriptPath),
powershell: buildHookCommand(config.hookScriptPath),
timeoutSec: 5,
});
hooks.postToolUse = postFiltered;
settings.hooks = hooks;
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
let settings: Record<string, unknown>;
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
return;
}
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (!hooks) return;
if (Array.isArray(hooks.preToolUse)) {
hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>, agentId);
}
if (Array.isArray(hooks.postToolUse)) {
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>, agentId);
}
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const settings = JSON.parse(raw);
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (hooks) {
const check = (arr: unknown) =>
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).some(
(h) =>
(typeof h.bash === 'string' && h.bash.includes('elydora')) ||
(typeof h.powershell === 'string' && h.powershell.includes('elydora')),
);
hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse);
// Extract hook script path from postToolUse bash/powershell command
if (hookConfigured && Array.isArray(hooks.postToolUse)) {
for (const h of hooks.postToolUse as Array<Record<string, unknown>>) {
const cmd = (h.bash || h.powershell) as string;
if (cmd && cmd.includes('elydora')) {
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
}
}
}
}
} catch {
// Config not readable
}
let hookScriptExists = false;
if (hookScriptPath) {
try {
await fsp.access(hookScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
};
},
};
+151
View File
@@ -0,0 +1,151 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'cursor';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
// .cursor is project-relative, but for global install use home dir
return path.join(os.homedir(), entry.configDir);
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function buildHookCommand(scriptPath: string): string {
return `node "${scriptPath}"`;
}
function isElydoraCommand(cmd: string, agentId?: string): boolean {
if (!cmd.includes('elydora')) return false;
if (agentId) {
return cmd.includes(agentId);
}
return true;
}
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
return arr.filter(
(h) => typeof h.command === 'string' && !isElydoraCommand(h.command, agentId),
);
}
export const cursorPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath();
let settings: Record<string, unknown> = {};
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
// Start fresh
}
if (!settings.hooks || typeof settings.hooks !== 'object') {
settings.hooks = {};
}
const hooks = settings.hooks as Record<string, unknown>;
// --- preToolUse (guard — freeze enforcement) ---
if (!Array.isArray(hooks.preToolUse)) {
hooks.preToolUse = [];
}
const preFiltered = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
preFiltered.push({ command: buildHookCommand(config.guardScriptPath) });
hooks.preToolUse = preFiltered;
// --- postToolUse (audit logging) ---
if (!Array.isArray(hooks.postToolUse)) {
hooks.postToolUse = [];
}
const postFiltered = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
postFiltered.push({ command: buildHookCommand(config.hookScriptPath) });
hooks.postToolUse = postFiltered;
settings.hooks = hooks;
settings.version = 1;
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
let settings: Record<string, unknown>;
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
return;
}
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (!hooks) return;
if (Array.isArray(hooks.preToolUse)) {
hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>, agentId);
}
if (Array.isArray(hooks.postToolUse)) {
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>, agentId);
}
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const settings = JSON.parse(raw);
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (hooks) {
const check = (arr: unknown) =>
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).some(
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
);
hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse);
// Extract hook script path from postToolUse command
if (hookConfigured && Array.isArray(hooks.postToolUse)) {
for (const h of hooks.postToolUse as Array<Record<string, unknown>>) {
const cmd = h.command as string;
if (cmd && cmd.includes('elydora')) {
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
}
}
}
}
} catch {
// Config not readable
}
let hookScriptExists = false;
if (hookScriptPath) {
try {
await fsp.access(hookScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
};
},
};
+169
View File
@@ -0,0 +1,169 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'gemini';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
return entry.configDir.replace(/^~/, os.homedir());
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function buildHookCommand(scriptPath: string): string {
return `node "${scriptPath}"`;
}
function isElydoraCommand(cmd: string, agentId?: string): boolean {
if (!cmd.includes('elydora')) return false;
if (agentId) {
return cmd.includes(agentId);
}
return true;
}
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
return arr.filter((entry) => {
if (Array.isArray(entry.hooks)) {
const cmds = entry.hooks as Array<Record<string, unknown>>;
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
}
if (typeof entry.command === 'string') return !isElydoraCommand(entry.command, agentId);
return true;
});
}
export const geminiPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath();
let settings: Record<string, unknown> = {};
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
// Start fresh
}
if (!settings.hooks || typeof settings.hooks !== 'object') {
settings.hooks = {};
}
const hooks = settings.hooks as Record<string, unknown>;
// --- BeforeTool (guard — freeze enforcement) ---
if (!Array.isArray(hooks.BeforeTool)) {
hooks.BeforeTool = [];
}
const preFiltered = filterElydoraEntries(hooks.BeforeTool as Array<Record<string, unknown>>);
preFiltered.push({
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
});
hooks.BeforeTool = preFiltered;
// --- AfterTool (audit logging) ---
if (!Array.isArray(hooks.AfterTool)) {
hooks.AfterTool = [];
}
const postFiltered = filterElydoraEntries(hooks.AfterTool as Array<Record<string, unknown>>);
postFiltered.push({
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
});
hooks.AfterTool = postFiltered;
settings.hooks = hooks;
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
let settings: Record<string, unknown>;
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
return;
}
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (!hooks) return;
if (Array.isArray(hooks.BeforeTool)) {
hooks.BeforeTool = filterElydoraEntries(hooks.BeforeTool as Array<Record<string, unknown>>, agentId);
}
if (Array.isArray(hooks.AfterTool)) {
hooks.AfterTool = filterElydoraEntries(hooks.AfterTool as Array<Record<string, unknown>>, agentId);
}
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const settings = JSON.parse(raw);
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (hooks) {
const checkArr = (arr: unknown) => {
if (!Array.isArray(arr)) return false;
return (arr as Array<Record<string, unknown>>).some((entry) => {
if (Array.isArray(entry.hooks)) {
return (entry.hooks as Array<Record<string, unknown>>).some(
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
);
}
return typeof entry.command === 'string' && entry.command.includes('elydora');
});
};
hookConfigured = checkArr(hooks.BeforeTool) && checkArr(hooks.AfterTool);
// Extract hook script path from AfterTool command
if (hookConfigured && Array.isArray(hooks.AfterTool)) {
for (const e of hooks.AfterTool as Array<Record<string, unknown>>) {
if (Array.isArray(e.hooks)) {
for (const h of e.hooks as Array<Record<string, unknown>>) {
const cmd = h.command as string;
if (cmd && cmd.includes('elydora')) {
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
}
}
}
}
}
}
} catch {
// Config not readable
}
let hookScriptExists = false;
if (hookScriptPath) {
try {
await fsp.access(hookScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
};
},
};
+401
View File
@@ -0,0 +1,401 @@
/**
* Generates a self-contained Node.js hook script that embeds all crypto logic
* inline so it has zero runtime dependency on the SDK.
*/
export function generateHookScript(agentName: string, agentId: string): string {
return `#!/usr/bin/env node
'use strict';
// ---------------------------------------------------------------------------
// Self-contained Elydora audit hook for: ${agentName}
// Generated by @elydora/sdk — DO NOT EDIT
// ---------------------------------------------------------------------------
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const AGENT_NAME = ${JSON.stringify(agentName)};
const AGENT_ID = ${JSON.stringify(agentId)};
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key');
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json');
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log');
// ---------------------------------------------------------------------------
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
// ---------------------------------------------------------------------------
function base64urlEncode(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
return buf.toString('base64url');
}
function jcsCanonicalise(value) {
if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value);
if (typeof value === 'string') return JSON.stringify(value);
if (Array.isArray(value)) {
return '[' + value.map(v => jcsCanonicalise(v)).join(',') + ']';
}
if (typeof value === 'object') {
const keys = Object.keys(value).sort();
const pairs = [];
for (const key of keys) {
if (value[key] !== undefined) {
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(value[key]));
}
}
return '{' + pairs.join(',') + '}';
}
return JSON.stringify(value);
}
function sha256Base64url(data) {
const input = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
return base64urlEncode(crypto.createHash('sha256').update(input).digest());
}
function computePayloadHash(payload) {
return sha256Base64url(jcsCanonicalise(payload));
}
function computeChainHash(prevChainHash, payloadHash, operationId, issuedAt) {
const input = prevChainHash + '|' + payloadHash + '|' + operationId + '|' + issuedAt;
return sha256Base64url(input);
}
const PKCS8_ED25519_PREFIX = Buffer.from([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05,
0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
function signEd25519(privateKeyBase64url, data) {
const seed = Buffer.from(privateKeyBase64url, 'base64url');
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
const keyObject = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
const signature = crypto.sign(null, data, keyObject);
return base64urlEncode(signature);
}
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
function uuidv7() {
const now = Date.now();
const random = crypto.randomBytes(10);
const bytes = Buffer.alloc(16);
bytes[0] = (now / 2 ** 40) & 0xff;
bytes[1] = (now / 2 ** 32) & 0xff;
bytes[2] = (now / 2 ** 24) & 0xff;
bytes[3] = (now / 2 ** 16) & 0xff;
bytes[4] = (now / 2 ** 8) & 0xff;
bytes[5] = now & 0xff;
bytes[6] = 0x70 | (random[0] & 0x0f);
bytes[7] = random[1];
bytes[8] = 0x80 | (random[2] & 0x3f);
bytes[9] = random[3];
bytes[10] = random[4];
bytes[11] = random[5];
bytes[12] = random[6];
bytes[13] = random[7];
bytes[14] = random[8];
bytes[15] = random[9];
const hex = bytes.toString('hex');
return (
hex.slice(0, 8) + '-' +
hex.slice(8, 12) + '-' +
hex.slice(12, 16) + '-' +
hex.slice(16, 20) + '-' +
hex.slice(20, 32)
);
}
function generateNonce() {
return base64urlEncode(crypto.randomBytes(16));
}
// ---------------------------------------------------------------------------
// Chain state persistence (atomic writes)
// ---------------------------------------------------------------------------
function readChainState() {
try {
const raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
const state = JSON.parse(raw);
return state.prev_chain_hash || ZERO_CHAIN_HASH;
} catch {
return ZERO_CHAIN_HASH;
}
}
function writeChainState(chainHash) {
const tmpPath = CHAIN_STATE_PATH + '.tmp';
fs.writeFileSync(tmpPath, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8');
try {
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
} catch (err) {
if (err.code === 'EPERM' || err.code === 'EEXIST') {
try { fs.unlinkSync(CHAIN_STATE_PATH); } catch { /* ignore */ }
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
} else {
throw err;
}
}
}
// ---------------------------------------------------------------------------
// Error logging
// ---------------------------------------------------------------------------
function logError(err) {
try {
const ts = new Date().toISOString();
const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\\n';
fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8');
} catch {
// Silently ignore logging failures
}
}
// ---------------------------------------------------------------------------
// Main hook logic
// ---------------------------------------------------------------------------
async function main() {
try {
// Read JSON from stdin
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
const rawInput = Buffer.concat(chunks).toString('utf-8').trim();
if (!rawInput) return;
let hookData;
try {
hookData = JSON.parse(rawInput);
} catch {
return;
}
// Extract tool_name and tool_input from hook data
// Different agents may pass data in different shapes
const toolName = hookData.tool_name || hookData.toolName || hookData.name || 'unknown';
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || {};
const sessionId = hookData.session_id || hookData.sessionId || hookData.session || 'unknown';
// Read agent config and private key
let config;
let privateKey;
try {
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim();
} catch (err) {
logError(new Error('Failed to read agent config/key: ' + err.message));
return;
}
const orgId = config.org_id;
const agentId = config.agent_id;
const kid = config.kid;
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\\/+$/, '');
const token = config.token || '';
// Read chain state
const prevChainHash = readChainState();
// Build EOR
const operationId = uuidv7();
const issuedAt = Date.now();
const nonce = generateNonce();
const payload = {
tool_name: toolName,
tool_input: toolInput,
session_id: sessionId,
};
const payloadHash = computePayloadHash(payload);
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
const eorWithoutSig = {
op_version: '1.0',
operation_id: operationId,
org_id: orgId,
agent_id: agentId,
issued_at: issuedAt,
ttl_ms: 30000,
nonce: nonce,
operation_type: 'ai.tool_use',
subject: { session_id: sessionId },
action: { tool: toolName },
payload: payload,
payload_hash: payloadHash,
prev_chain_hash: prevChainHash,
agent_pubkey_kid: kid,
};
const canonical = jcsCanonicalise(eorWithoutSig);
const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8'));
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
// POST to /v1/operations (5s timeout)
// Only update local chain state on success to prevent desync
const url = baseUrl + '/v1/operations';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const res = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(eor),
signal: controller.signal,
});
if (res.ok) {
// Server accepted — safe to advance local chain state
writeChainState(chainHash);
} else if (res.status === 400) {
// Possible chain hash mismatch — try to resync from server response
try {
const errBody = await res.json();
if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
// Extract the expected hash from the error message
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
if (match) {
writeChainState(match[1]);
logError(new Error('Chain hash resynced to server: ' + match[1]));
}
}
} catch { /* ignore parse errors */ }
}
} catch {
// Network error — don't advance chain state, will retry with same prev_chain_hash
} finally {
clearTimeout(timeout);
}
} catch (err) {
logError(err);
}
}
main();
`;
}
/**
* Generates a lightweight PreToolUse guard script that checks if the agent
* is frozen and blocks tool execution if so.
*
* - Caches the agent status locally (60s TTL) to avoid hitting the API on every tool call
* - Fails open: if the API is unreachable, tool execution is allowed
* - If frozen: exits with code 1 to block the tool
*/
export function generateGuardScript(agentName: string, agentId: string): string {
return `#!/usr/bin/env node
'use strict';
// ---------------------------------------------------------------------------
// Elydora agent guard (PreToolUse) for: ${agentName}
// Generated by @elydora/sdk — DO NOT EDIT
// ---------------------------------------------------------------------------
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const AGENT_NAME = ${JSON.stringify(agentName)};
const AGENT_ID = ${JSON.stringify(agentId)};
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json');
const CACHE_TTL_MS = 60000; // 60 seconds
// Discard stdin immediately so the process can exit cleanly
process.stdin.destroy();
async function main() {
// Read agent config
let config;
try {
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return; // Can't read config — fail-open (allow)
}
// Check cached status
try {
const raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8');
const cache = JSON.parse(raw);
const age = Date.now() - (cache.cached_at || 0);
if (age < CACHE_TTL_MS) {
if (cache.status === 'frozen') {
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n');
process.exitCode = 2;
}
return; // Cache is fresh — use cached result
}
} catch {
// No cache or invalid — need to check API
}
// Fetch agent status from API
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\\/+$/, '');
const token = config.token || '';
const agentId = config.agent_id;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const headers = { 'Accept': 'application/json' };
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const res = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(agentId), {
headers: headers,
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
const data = await res.json();
const agentStatus = (data.agent && data.agent.status) || 'active';
// Update cache
try {
fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({
status: agentStatus,
cached_at: Date.now(),
}), 'utf-8');
} catch {
// Ignore cache write failures
}
if (agentStatus === 'frozen') {
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n');
process.exitCode = 2;
}
}
} catch {
// API unreachable — fail-open (allow)
}
}
main();
`;
}
+173
View File
@@ -0,0 +1,173 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'kirocli';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
return entry.configDir.replace(/^~/, os.homedir());
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function buildHookCommand(scriptPath: string): string {
return `node "${scriptPath}"`;
}
function isElydoraCommand(cmd: string, agentId?: string): boolean {
if (!cmd.includes('elydora')) return false;
if (agentId) {
return cmd.includes(agentId);
}
return true;
}
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
return arr.filter((entry) => {
if (Array.isArray(entry.hooks)) {
const cmds = entry.hooks as Array<Record<string, unknown>>;
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
}
if (typeof entry.command === 'string') {
return !isElydoraCommand(entry.command, agentId);
}
return true;
});
}
export const kirocliPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath();
let settings: Record<string, unknown> = {};
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
// File doesn't exist or isn't valid JSON — start fresh
}
if (!settings.hooks || typeof settings.hooks !== 'object') {
settings.hooks = {};
}
const hooks = settings.hooks as Record<string, unknown>;
// --- PreToolUse (guard — freeze enforcement) ---
if (!Array.isArray(hooks.PreToolUse)) {
hooks.PreToolUse = [];
}
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
preFiltered.push({
matcher: '*',
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath), timeout_ms: 5000 }],
});
hooks.PreToolUse = preFiltered;
// --- PostToolUse (audit logging) ---
if (!Array.isArray(hooks.PostToolUse)) {
hooks.PostToolUse = [];
}
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
postFiltered.push({
matcher: '*',
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath), timeout_ms: 5000 }],
});
hooks.PostToolUse = postFiltered;
settings.hooks = hooks;
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
let settings: Record<string, unknown>;
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
return; // Nothing to uninstall
}
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (!hooks) return;
if (Array.isArray(hooks.PreToolUse)) {
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
}
if (Array.isArray(hooks.PostToolUse)) {
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
}
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const settings = JSON.parse(raw);
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (hooks) {
const checkArr = (arr: unknown) => {
if (!Array.isArray(arr)) return false;
return (arr as Array<Record<string, unknown>>).some((entry) => {
if (Array.isArray(entry.hooks)) {
return (entry.hooks as Array<Record<string, unknown>>).some(
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
);
}
return typeof entry.command === 'string' && entry.command.includes('elydora');
});
};
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
// Extract hook script path from PostToolUse command
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
if (Array.isArray(e.hooks)) {
for (const h of e.hooks as Array<Record<string, unknown>>) {
const cmd = h.command as string;
if (cmd && cmd.includes('elydora')) {
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
}
}
}
}
}
}
} catch {
// Config not readable
}
let hookScriptExists = false;
if (hookScriptPath) {
try {
await fsp.access(hookScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
};
},
};
+94
View File
@@ -0,0 +1,94 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'kiroide';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
// .kiro/hooks is project-relative, but for global install use home dir
return path.join(os.homedir(), entry.configDir);
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
export const kiroidePlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath();
const hookConfig = {
name: 'Elydora Audit',
description: 'Sends tool-use events to the Elydora tamper-evident audit platform',
version: '1.0.0',
hooks: {
pre_tool_use: {
command: `node "${config.guardScriptPath}"`,
timeout_ms: 5000,
},
post_tool_use: {
command: `node "${config.hookScriptPath}"`,
timeout_ms: 5000,
},
},
};
await fsp.writeFile(configPath, JSON.stringify(hookConfig, null, 2) + '\n', 'utf-8');
},
async uninstall(_agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
try {
await fsp.unlink(configPath);
} catch {
// Already removed
}
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const config = JSON.parse(raw);
hookConfigured = !!(config.hooks && config.hooks.pre_tool_use && config.hooks.post_tool_use);
// Extract hook script path from post_tool_use command
if (hookConfigured && config.hooks.post_tool_use) {
const cmd = config.hooks.post_tool_use.command as string;
if (cmd && cmd.includes('elydora')) {
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
}
}
} catch {
// Config not readable
}
let hookScriptExists = false;
if (hookScriptPath) {
try {
await fsp.access(hookScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
};
},
};
+173
View File
@@ -0,0 +1,173 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'letta';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
return entry.configDir.replace(/^~/, os.homedir());
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function buildHookCommand(scriptPath: string): string {
return `node "${scriptPath}"`;
}
function isElydoraCommand(cmd: string, agentId?: string): boolean {
if (!cmd.includes('elydora')) return false;
if (agentId) {
return cmd.includes(agentId);
}
return true;
}
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
return arr.filter((entry) => {
if (Array.isArray(entry.hooks)) {
const cmds = entry.hooks as Array<Record<string, unknown>>;
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
}
if (typeof entry.command === 'string') {
return !isElydoraCommand(entry.command, agentId);
}
return true;
});
}
export const lettaPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath();
let settings: Record<string, unknown> = {};
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
// File doesn't exist or isn't valid JSON — start fresh
}
if (!settings.hooks || typeof settings.hooks !== 'object') {
settings.hooks = {};
}
const hooks = settings.hooks as Record<string, unknown>;
// --- PreToolUse (guard — freeze enforcement) ---
if (!Array.isArray(hooks.PreToolUse)) {
hooks.PreToolUse = [];
}
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
preFiltered.push({
matcher: '*',
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
});
hooks.PreToolUse = preFiltered;
// --- PostToolUse (audit logging) ---
if (!Array.isArray(hooks.PostToolUse)) {
hooks.PostToolUse = [];
}
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
postFiltered.push({
matcher: '*',
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
});
hooks.PostToolUse = postFiltered;
settings.hooks = hooks;
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
let settings: Record<string, unknown>;
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
return; // Nothing to uninstall
}
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (!hooks) return;
if (Array.isArray(hooks.PreToolUse)) {
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
}
if (Array.isArray(hooks.PostToolUse)) {
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
}
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const settings = JSON.parse(raw);
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (hooks) {
const checkArr = (arr: unknown) => {
if (!Array.isArray(arr)) return false;
return (arr as Array<Record<string, unknown>>).some((entry) => {
if (Array.isArray(entry.hooks)) {
return (entry.hooks as Array<Record<string, unknown>>).some(
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
);
}
return typeof entry.command === 'string' && entry.command.includes('elydora');
});
};
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
// Extract hook script path from PostToolUse command
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
if (Array.isArray(e.hooks)) {
for (const h of e.hooks as Array<Record<string, unknown>>) {
const cmd = h.command as string;
if (cmd && cmd.includes('elydora')) {
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
}
}
}
}
}
}
} catch {
// Config not readable
}
let hookScriptExists = false;
if (hookScriptPath) {
try {
await fsp.access(hookScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
};
},
};
+134
View File
@@ -0,0 +1,134 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'opencode';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
return path.join(os.homedir(), entry.configDir);
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function generatePluginScript(hookScriptPath: string, guardScriptPath: string): string {
return `// Elydora Audit Plugin for OpenCode
// Generated by @elydora/sdk — DO NOT EDIT
import { spawn, spawnSync } from 'node:child_process';
export const ElydoraAuditPlugin = async (ctx) => {
return {
"tool.execute.before": async (input, output) => {
// Guard — blocks tool if agent is frozen
try {
const result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], {
timeout: 5000,
stdio: ['pipe', 'ignore', 'pipe'],
});
if (result.status === 2) {
const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.';
// Signal denial by setting a blocked flag
if (output && typeof output === 'object') {
output.blocked = true;
output.reason = msg;
}
}
} catch {
// Fail-open — allow if guard can't run
}
},
"tool.execute.after": async (input, output) => {
try {
const toolName = input?.tool || 'unknown';
const toolInput = output?.args || input?.args || {};
const sessionId = ctx?.project?.name || 'unknown';
const data = JSON.stringify({
tool_name: toolName,
tool_input: toolInput,
session_id: sessionId,
});
const child = spawn('node', ['__ELYDORA_HOOK_SCRIPT_PATH__'], {
detached: true,
stdio: ['pipe', 'ignore', 'ignore'],
});
child.stdin.write(data);
child.stdin.end();
child.unref();
} catch {
// Fire-and-forget — never block the developer
}
},
};
};
`;
}
export const opencodePlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath();
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath)
.replace('__ELYDORA_HOOK_SCRIPT_PATH__', config.hookScriptPath.replace(/\\/g, '\\\\'))
.replace('__ELYDORA_GUARD_SCRIPT_PATH__', config.guardScriptPath.replace(/\\/g, '\\\\'));
await fsp.writeFile(configPath, script, 'utf-8');
},
async uninstall(_agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
try {
await fsp.unlink(configPath);
} catch {
// Already removed
}
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
hookConfigured = true;
// Extract hook script path from plugin script content
// The plugin script contains a spawn call like: spawn('node', ['<path>'])
const match = raw.match(/spawn\('node',\s*\['([^']*elydora[^']*)'\]/);
if (match) {
hookScriptPath = match[1];
}
} catch {
// File doesn't exist
}
let hookScriptExists = false;
if (hookScriptPath) {
try {
await fsp.access(hookScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
};
},
};
+16
View File
@@ -0,0 +1,16 @@
export interface AgentRegistryEntry {
readonly name: string;
readonly configDir: string;
readonly configFile: string;
}
export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map([
['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }],
['cursor', { name: 'Cursor', configDir: '.cursor', configFile: 'hooks.json' }],
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro', configFile: 'settings.json' }],
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }],
['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }],
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
]);
+330
View File
@@ -0,0 +1,330 @@
// ---------------------------------------------------------------------------
// Enums / status types
// ---------------------------------------------------------------------------
export type AgentStatus = 'active' | 'frozen' | 'revoked';
export type KeyStatus = 'active' | 'retired' | 'revoked';
export type ExportStatus = 'queued' | 'running' | 'done' | 'failed';
export type RbacRole =
| 'org_owner'
| 'security_admin'
| 'compliance_auditor'
| 'readonly_investigator'
| 'integration_engineer';
export type ErrorCode =
| 'INVALID_SIGNATURE'
| 'UNKNOWN_AGENT'
| 'KEY_REVOKED'
| 'AGENT_FROZEN'
| 'TTL_EXPIRED'
| 'REPLAY_DETECTED'
| 'PREV_HASH_MISMATCH'
| 'PAYLOAD_TOO_LARGE'
| 'RATE_LIMITED'
| 'INTERNAL_ERROR'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'VALIDATION_ERROR';
// ---------------------------------------------------------------------------
// Entities
// ---------------------------------------------------------------------------
export interface Agent {
readonly agent_id: string;
readonly org_id: string;
readonly display_name: string;
readonly responsible_entity: string;
readonly status: AgentStatus;
readonly created_at: number;
readonly updated_at: number;
}
export interface AgentKey {
readonly kid: string;
readonly agent_id: string;
readonly public_key: string;
readonly algorithm: 'ed25519';
readonly status: KeyStatus;
readonly created_at: number;
readonly retired_at: number | null;
}
export interface Operation {
readonly operation_id: string;
readonly org_id: string;
readonly agent_id: string;
readonly seq_no: number;
readonly operation_type: string;
readonly issued_at: number;
readonly ttl_ms: number;
readonly nonce: string;
readonly subject: string;
readonly action: string;
readonly payload_hash: string;
readonly prev_chain_hash: string;
readonly chain_hash: string;
readonly agent_pubkey_kid: string;
readonly signature: string;
readonly r2_payload_key: string | null;
readonly created_at: number;
}
export interface Receipt {
readonly receipt_id: string;
readonly operation_id: string;
readonly r2_receipt_key: string;
readonly created_at: number;
}
export interface Epoch {
readonly epoch_id: string;
readonly org_id: string;
readonly start_time: number;
readonly end_time: number;
readonly root_hash: string;
readonly leaf_count: number;
readonly r2_epoch_key: string;
readonly created_at: number;
}
export interface Organization {
readonly org_id: string;
readonly name: string;
readonly created_at: number;
readonly updated_at: number;
}
export interface User {
readonly user_id: string;
readonly org_id: string;
readonly email: string;
readonly display_name: string;
readonly role: RbacRole;
readonly status: 'active' | 'suspended';
readonly created_at: number;
readonly updated_at: number;
}
export interface Export {
readonly export_id: string;
readonly org_id: string;
readonly status: ExportStatus;
readonly query_params: string;
readonly r2_export_key: string | null;
readonly created_at: number;
readonly completed_at: number | null;
}
// ---------------------------------------------------------------------------
// Protocol types
// ---------------------------------------------------------------------------
export interface EOR {
readonly op_version: '1.0';
readonly operation_id: string;
readonly org_id: string;
readonly agent_id: string;
readonly issued_at: number;
readonly ttl_ms: number;
readonly nonce: string;
readonly operation_type: string;
readonly subject: Record<string, unknown>;
readonly action: Record<string, unknown>;
readonly payload: Record<string, unknown> | string | null;
readonly payload_hash: string;
readonly prev_chain_hash: string;
readonly agent_pubkey_kid: string;
readonly signature: string;
}
export interface EAR {
readonly receipt_version: string;
readonly receipt_id: string;
readonly operation_id: string;
readonly org_id: string;
readonly agent_id: string;
readonly server_received_at: number;
readonly seq_no: number;
readonly chain_hash: string;
readonly queue_message_id: string;
readonly receipt_hash: string;
readonly elydora_kid: string;
readonly elydora_signature: string;
}
// ---------------------------------------------------------------------------
// API request/response types
// ---------------------------------------------------------------------------
export interface RegisterAgentRequest {
readonly agent_id: string;
readonly display_name?: string;
readonly responsible_entity?: string;
readonly keys: ReadonlyArray<{
readonly kid: string;
readonly public_key: string;
readonly algorithm: 'ed25519';
}>;
}
export interface RegisterAgentResponse {
readonly agent: Agent;
readonly keys: AgentKey[];
}
export interface GetAgentResponse {
readonly agent: Agent;
readonly keys: AgentKey[];
}
export interface SubmitOperationResponse {
readonly receipt: EAR;
}
export interface GetOperationResponse {
readonly operation: Operation;
readonly receipt?: Receipt;
}
export interface VerifyOperationResponse {
readonly valid: boolean;
readonly checks: {
readonly signature: boolean;
readonly chain: boolean;
readonly receipt: boolean;
readonly merkle?: boolean;
};
readonly errors?: string[];
}
export interface AuditQueryRequest {
readonly org_id?: string;
readonly agent_id?: string;
readonly operation_type?: string;
readonly start_time?: number;
readonly end_time?: number;
readonly cursor?: string;
readonly limit?: number;
}
export interface AuditQueryResponse {
readonly operations: Operation[];
readonly cursor?: string;
readonly total_count: number;
}
export interface GetEpochResponse {
readonly epoch: Epoch;
readonly anchor?: {
readonly tsa_token?: string;
};
}
export interface ListEpochsResponse {
readonly epochs: Epoch[];
}
export interface CreateExportRequest {
readonly start_time: number;
readonly end_time: number;
readonly agent_id?: string;
readonly operation_type?: string;
readonly format: 'json' | 'pdf';
}
export interface CreateExportResponse {
readonly export: Export;
}
export interface GetExportResponse {
readonly export: Export;
readonly download_url?: string;
}
export interface ListExportsResponse {
readonly exports: Export[];
}
export interface ListAgentsResponse {
readonly agents: Agent[];
}
export interface UnfreezeAgentResponse {
readonly agent: Agent;
}
export interface DeleteAgentResponse {
readonly deleted: boolean;
}
export interface GetMeResponse {
readonly user: User;
}
export interface IssueApiTokenResponse {
readonly token: string;
readonly expires_at: number | null;
}
export interface HealthResponse {
readonly status: string;
readonly version: string;
readonly protocol_version: string;
readonly timestamp: number;
}
export interface JWK {
readonly kty: string;
readonly crv?: string;
readonly x?: string;
readonly kid: string;
readonly use: string;
readonly alg: string;
}
export interface JWKSResponse {
readonly keys: JWK[];
}
export interface AuthRegisterResponse {
readonly user: User;
readonly organization: Organization;
readonly token: string;
}
export interface AuthLoginResponse {
readonly user: User;
readonly token: string;
}
export interface ErrorResponse {
readonly error: {
readonly code: ErrorCode;
readonly message: string;
readonly request_id: string;
readonly details?: Record<string, unknown>;
};
}
// ---------------------------------------------------------------------------
// Client configuration
// ---------------------------------------------------------------------------
export interface ElydoraClientConfig {
readonly orgId: string;
readonly agentId: string;
readonly privateKey: string;
readonly baseUrl?: string;
readonly ttlMs?: number;
readonly maxRetries?: number;
readonly kid?: string;
}
export interface CreateOperationParams {
readonly operationType: string;
readonly subject: Record<string, unknown>;
readonly action: Record<string, unknown>;
readonly payload?: Record<string, unknown> | string | null;
}
+68
View File
@@ -0,0 +1,68 @@
import crypto from 'node:crypto';
/**
* Generate a UUIDv7 (time-ordered UUID per RFC 9562).
*
* Layout (128 bits):
* 48-bit Unix timestamp (ms) | 4-bit version (7) | 12-bit rand_a
* 2-bit variant (10) | 62-bit rand_b
*/
export function uuidv7(): string {
const now = Date.now();
const random = crypto.randomBytes(10);
// Bytes 0-5: 48-bit timestamp (ms)
const bytes = Buffer.alloc(16);
bytes[0] = (now / 2 ** 40) & 0xff;
bytes[1] = (now / 2 ** 32) & 0xff;
bytes[2] = (now / 2 ** 24) & 0xff;
bytes[3] = (now / 2 ** 16) & 0xff;
bytes[4] = (now / 2 ** 8) & 0xff;
bytes[5] = now & 0xff;
// Bytes 6-7: version (0111) + 12 bits random
bytes[6] = 0x70 | (random[0]! & 0x0f);
bytes[7] = random[1]!;
// Bytes 8-15: variant (10) + 62 bits random
bytes[8] = 0x80 | (random[2]! & 0x3f);
bytes[9] = random[3]!;
bytes[10] = random[4]!;
bytes[11] = random[5]!;
bytes[12] = random[6]!;
bytes[13] = random[7]!;
bytes[14] = random[8]!;
bytes[15] = random[9]!;
const hex = bytes.toString('hex');
return (
hex.slice(0, 8) + '-' +
hex.slice(8, 12) + '-' +
hex.slice(12, 16) + '-' +
hex.slice(16, 20) + '-' +
hex.slice(20, 32)
);
}
/**
* Generate a random nonce: 16 random bytes, base64url encoded.
*/
export function generateNonce(): string {
const bytes = crypto.randomBytes(16);
return base64urlEncode(bytes);
}
/**
* Encode a Buffer or Uint8Array to base64url (no padding, RFC 4648 section 5).
*/
export function base64urlEncode(data: Buffer | Uint8Array): string {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
return buf.toString('base64url');
}
/**
* Decode a base64url string (no padding) to a Buffer.
*/
export function base64urlDecode(input: string): Buffer {
return Buffer.from(input, 'base64url');
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}