Initial commit: Elydora Node SDK
TypeScript SDK for Elydora agent operations, cryptographic signing, and audit trail integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
65910a3ad6
23 files changed
+2663
No files matched your search
+264
@@ -0,0 +1,264 @@
|
||||
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 { augmentPlugin } from './plugins/augment.js';
|
||||
import { kiroPlugin } from './plugins/kiro.js';
|
||||
import { opencodePlugin } from './plugins/opencode.js';
|
||||
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
|
||||
const PLUGINS: ReadonlyMap<string, AgentPlugin> = new Map([
|
||||
['claudecode', claudecodePlugin],
|
||||
['cursor', cursorPlugin],
|
||||
['gemini', geminiPlugin],
|
||||
['augment', augmentPlugin],
|
||||
['kiro', kiroPlugin],
|
||||
['opencode', opencodePlugin],
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>
|
||||
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 directory structure
|
||||
const agentsDir = path.join(ELYDORA_DIR, 'agents');
|
||||
const hooksDir = path.join(ELYDORA_DIR, 'hooks');
|
||||
await fsp.mkdir(agentsDir, { recursive: true });
|
||||
await fsp.mkdir(hooksDir, { recursive: true });
|
||||
|
||||
// Write agent config
|
||||
const agentConfigPath = path.join(agentsDir, `${agentName}.json`);
|
||||
const agentConfig = {
|
||||
org_id: orgId,
|
||||
agent_id: agentId,
|
||||
kid,
|
||||
base_url: baseUrl,
|
||||
...(token ? { token } : {}),
|
||||
};
|
||||
await fsp.writeFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n', 'utf-8');
|
||||
console.log(` Agent config: ${agentConfigPath}`);
|
||||
|
||||
// Write private key (chmod 600)
|
||||
const keyPath = path.join(agentsDir, `${agentName}.key`);
|
||||
await fsp.writeFile(keyPath, privateKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
console.log(` Private key: ${keyPath}`);
|
||||
|
||||
// Generate and write hook script (PostToolUse — audit logging)
|
||||
const hookScriptPath = path.join(hooksDir, `${agentName}-hook.js`);
|
||||
const hookScript = generateHookScript(agentName);
|
||||
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(hooksDir, `${agentName}-guard.js`);
|
||||
const guardScript = generateGuardScript(agentName);
|
||||
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' },
|
||||
},
|
||||
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 plugin = PLUGINS.get(agentName)!;
|
||||
const entry = SUPPORTED_AGENTS.get(agentName)!;
|
||||
|
||||
// Uninstall agent-specific config
|
||||
await plugin.uninstall();
|
||||
|
||||
// Remove hook and guard scripts
|
||||
const hookScriptPath = path.join(ELYDORA_DIR, 'hooks', `${agentName}-hook.js`);
|
||||
const guardScriptPath = path.join(ELYDORA_DIR, 'hooks', `${agentName}-guard.js`);
|
||||
try { await fsp.unlink(hookScriptPath); } catch { /* Already removed */ }
|
||||
try { await fsp.unlink(guardScriptPath); } catch { /* Already removed */ }
|
||||
|
||||
// Remove agent config and key
|
||||
const agentConfigPath = path.join(ELYDORA_DIR, 'agents', `${agentName}.json`);
|
||||
const keyPath = path.join(ELYDORA_DIR, 'agents', `${agentName}.key`);
|
||||
try { await fsp.unlink(agentConfigPath); } catch { /* */ }
|
||||
try { await fsp.unlink(keyPath); } catch { /* */ }
|
||||
|
||||
console.log(`Elydora audit hook uninstalled for ${entry.name}.`);
|
||||
}
|
||||
|
||||
async function cmdStatus(): Promise<void> {
|
||||
console.log('Elydora Agent Status\n');
|
||||
|
||||
let anyInstalled = false;
|
||||
|
||||
for (const [name, plugin] of PLUGINS) {
|
||||
const st = await plugin.status();
|
||||
const statusIcon = st.installed ? '[installed]' : '[not installed]';
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
import type {
|
||||
ElydoraClientConfig,
|
||||
CreateOperationParams,
|
||||
EOR,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
GetAgentResponse,
|
||||
SubmitOperationResponse,
|
||||
GetOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
AuditQueryRequest,
|
||||
AuditQueryResponse,
|
||||
ListEpochsResponse,
|
||||
GetEpochResponse,
|
||||
CreateExportRequest,
|
||||
CreateExportResponse,
|
||||
ListExportsResponse,
|
||||
GetExportResponse,
|
||||
JWKSResponse,
|
||||
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);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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 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)}`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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));
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
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: SHA-256 of 32 zero bytes, base64url encoded.
|
||||
*/
|
||||
export const ZERO_CHAIN_HASH: string = base64urlEncode(
|
||||
crypto.createHash('sha256').update(Buffer.alloc(32, 0)).digest(),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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,
|
||||
SubmitOperationResponse,
|
||||
GetOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
AuditQueryRequest,
|
||||
AuditQueryResponse,
|
||||
GetEpochResponse,
|
||||
ListEpochsResponse,
|
||||
CreateExportRequest,
|
||||
CreateExportResponse,
|
||||
GetExportResponse,
|
||||
ListExportsResponse,
|
||||
JWK,
|
||||
JWKSResponse,
|
||||
AuthRegisterResponse,
|
||||
AuthLoginResponse,
|
||||
ErrorResponse,
|
||||
} from './types.js';
|
||||
@@ -0,0 +1,145 @@
|
||||
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 = 'augment';
|
||||
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 filterElydoraEntries(arr: Array<Record<string, unknown>>): 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' && h.command.includes('elydora'));
|
||||
}
|
||||
if (typeof entry.command === 'string') return !entry.command.includes('elydora');
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const augmentPlugin: 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({
|
||||
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(): 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>>);
|
||||
}
|
||||
if (Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||
|
||||
let hookConfigured = false;
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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(): Promise<void>;
|
||||
status(): Promise<PluginStatus>;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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 filterElydoraEntries(arr: Array<Record<string, unknown>>): 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' && h.command.includes('elydora'));
|
||||
}
|
||||
if (typeof entry.command === 'string') {
|
||||
return !entry.command.includes('elydora');
|
||||
}
|
||||
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(): 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>>);
|
||||
}
|
||||
if (Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||
|
||||
let hookConfigured = false;
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
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 filterElydoraEntries(arr: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
||||
return arr.filter(
|
||||
(h) => typeof h.command === 'string' && !h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async uninstall(): 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>>);
|
||||
}
|
||||
if (Array.isArray(hooks.postToolUse)) {
|
||||
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||
|
||||
let hookConfigured = false;
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
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 filterElydoraEntries(arr: Array<Record<string, unknown>>): 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' && h.command.includes('elydora'));
|
||||
}
|
||||
if (typeof entry.command === 'string') return !entry.command.includes('elydora');
|
||||
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(): 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>>);
|
||||
}
|
||||
if (Array.isArray(hooks.AfterTool)) {
|
||||
hooks.AfterTool = filterElydoraEntries(hooks.AfterTool as Array<Record<string, unknown>>);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||
|
||||
let hookConfigured = false;
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* 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): 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 ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.json');
|
||||
const KEY_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.key');
|
||||
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, 'chain-state.json');
|
||||
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, '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): 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 ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.json');
|
||||
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.status.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();
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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 = 'kiro';
|
||||
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 kiroPlugin: 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(): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fsp.unlink(configPath);
|
||||
} catch {
|
||||
// Already removed
|
||||
}
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||
|
||||
let hookConfigured = false;
|
||||
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);
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
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 {
|
||||
// .opencode/plugins 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 generatePluginScript(hookScriptPath: string, guardScriptPath: string): string {
|
||||
return `// Elydora Audit Plugin for OpenCode
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
|
||||
module.exports = {
|
||||
name: 'elydora-audit',
|
||||
version: '1.0.0',
|
||||
description: 'Sends tool-use events to the Elydora tamper-evident audit platform',
|
||||
|
||||
hooks: {
|
||||
preToolUse(context) {
|
||||
// Synchronous 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 !== 0) {
|
||||
const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.';
|
||||
return { blocked: true, reason: msg };
|
||||
}
|
||||
} catch {
|
||||
// Fail-open — allow if guard can't run
|
||||
}
|
||||
},
|
||||
|
||||
postToolUse(context) {
|
||||
try {
|
||||
const input = JSON.stringify({
|
||||
tool_name: context.toolName || context.tool_name || 'unknown',
|
||||
tool_input: context.toolInput || context.tool_input || {},
|
||||
session_id: context.sessionId || context.session_id || 'unknown',
|
||||
});
|
||||
|
||||
const child = spawn('node', ['__ELYDORA_HOOK_SCRIPT_PATH__'], {
|
||||
detached: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
});
|
||||
child.stdin.write(input);
|
||||
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(): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fsp.unlink(configPath);
|
||||
} catch {
|
||||
// Already removed
|
||||
}
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||
|
||||
let hookConfigured = false;
|
||||
try {
|
||||
await fsp.access(configPath);
|
||||
hookConfigured = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
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' }],
|
||||
['augment', { name: 'Augment Code', configDir: '~/.augment', configFile: 'settings.json' }],
|
||||
['kiro', { name: 'Kiro', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
|
||||
['opencode', { name: 'OpenCode', configDir: '.opencode/plugins', configFile: 'elydora-audit.js' }],
|
||||
]);
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
Reference in new issue
Block a user