commit 65910a3ad61d414d1c6dc8f4d32f7e80a898e57c Author: ZacharyZhang Date: Sat Feb 28 18:03:07 2026 -0500 Initial commit: Elydora Node SDK TypeScript SDK for Elydora agent operations, cryptographic signing, and audit trail integration. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..5206790 --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +src/ +tsconfig.json +.gitignore +node_modules/ +*.tsbuildinfo diff --git a/README.md b/README.md new file mode 100644 index 0000000..0833e31 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# @elydora/sdk + +Node.js/TypeScript SDK for the Elydora tamper-evident audit platform. + +## 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'; + +// 1. Authenticate +const auth = await ElydoraClient.login('https://api.elydora.com', 'user@example.com', 'password'); + +// 2. Create client +const client = new ElydoraClient({ + orgId: auth.user.org_id, + agentId: 'my-agent-id', + privateKey: '', + baseUrl: 'https://api.elydora.com', +}); +client.setToken(auth.token); + +// 3. 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); +``` + +## API Reference + +### Static Methods + +- `ElydoraClient.register(baseUrl, email, password, displayName?, orgName?)` — Register a new user and organization +- `ElydoraClient.login(baseUrl, email, password)` — Authenticate and receive a JWT + +### Instance Methods + +#### Agent Management +- `registerAgent(request)` — Register a new agent with public keys +- `getAgent(agentId)` — Retrieve agent details +- `freezeAgent(agentId, reason)` — Freeze an agent +- `revokeKey(agentId, kid, reason)` — Revoke an agent key + +#### Operations +- `createOperation(params)` — Construct and sign an EOR locally (synchronous) +- `submitOperation(eor)` — Submit a signed EOR to the API +- `getOperation(operationId)` — Retrieve an operation +- `verifyOperation(operationId)` — Verify operation integrity + +#### Audit +- `queryAudit(params)` — Query the audit log + +#### Epochs +- `listEpochs()` — List all epochs +- `getEpoch(epochId)` — Retrieve an epoch + +#### Exports +- `createExport(params)` — Create a compliance export +- `listExports()` — List all exports +- `getExport(exportId)` — Retrieve export status + +#### JWKS +- `getJWKS()` — Retrieve platform public keys + +## Cryptographic Details + +- **Ed25519**: Signs operations using Node.js built-in `crypto` module +- **Chain hashing**: `SHA-256(prev_chain_hash | payload_hash | operation_id | issued_at)` +- **JCS**: RFC 8785 JSON Canonicalization Scheme for deterministic serialization +- **Base64url**: RFC 4648 section 5, no padding + +## License + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c73862a --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b7223c1 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "@elydora/sdk", + "version": "1.0.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" + } +} diff --git a/scripts/postbuild.mjs b/scripts/postbuild.mjs new file mode 100644 index 0000000..0b3ee85 --- /dev/null +++ b/scripts/postbuild.mjs @@ -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'); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..42b20f5 --- /dev/null +++ b/src/cli.ts @@ -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 = 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 --org_id --agent_id --private_key --kid [--token ] [--base_url ] + elydora uninstall --agent + 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 { + 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 { + 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 { + 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 " 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 " to install an audit hook.'); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + 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); +}); diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..661c40b --- /dev/null +++ b/src/client.ts @@ -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 { + const url = `${baseUrl.replace(/\/+$/, '')}/v1/auth/register`; + const body: Record = { 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(res); + } + + static async login( + baseUrl: string, + email: string, + password: string, + ): Promise { + 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(res); + } + + // ------------------------------------------------------------------------- + // Agent management + // ------------------------------------------------------------------------- + + async registerAgent(request: RegisterAgentRequest): Promise { + return this.request('POST', '/v1/agents/register', request); + } + + async getAgent(agentId: string): Promise { + return this.request('GET', `/v1/agents/${encodeURIComponent(agentId)}`); + } + + async freezeAgent(agentId: string, reason: string): Promise { + await this.request('POST', `/v1/agents/${encodeURIComponent(agentId)}/freeze`, { reason }); + } + + async revokeKey(agentId: string, kid: string, reason: string): Promise { + await this.request('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 = { + 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 { + return this.request('POST', '/v1/operations', eor); + } + + async getOperation(operationId: string): Promise { + return this.request('GET', `/v1/operations/${encodeURIComponent(operationId)}`); + } + + async verifyOperation(operationId: string): Promise { + return this.request('POST', `/v1/operations/${encodeURIComponent(operationId)}/verify`); + } + + // ------------------------------------------------------------------------- + // Audit + // ------------------------------------------------------------------------- + + async queryAudit(params: AuditQueryRequest): Promise { + return this.request('POST', '/v1/audit/query', params); + } + + // ------------------------------------------------------------------------- + // Epochs + // ------------------------------------------------------------------------- + + async listEpochs(): Promise { + return this.request('GET', '/v1/epochs'); + } + + async getEpoch(epochId: string): Promise { + return this.request('GET', `/v1/epochs/${encodeURIComponent(epochId)}`); + } + + // ------------------------------------------------------------------------- + // Exports + // ------------------------------------------------------------------------- + + async createExport(params: CreateExportRequest): Promise { + return this.request('POST', '/v1/exports', params); + } + + async listExports(): Promise { + return this.request('GET', '/v1/exports'); + } + + async getExport(exportId: string): Promise { + return this.request('GET', `/v1/exports/${encodeURIComponent(exportId)}`); + } + + // ------------------------------------------------------------------------- + // JWKS + // ------------------------------------------------------------------------- + + async getJWKS(): Promise { + const url = `${this.baseUrl}/.well-known/elydora/jwks.json`; + const res = await fetch(url, { + method: 'GET', + headers: { 'Accept': 'application/json' }, + }); + return handleResponse(res); + } + + // ------------------------------------------------------------------------- + // Internal HTTP helpers + // ------------------------------------------------------------------------- + + private async request( + method: string, + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path}`; + const headers: Record = { + '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(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(res: Response): Promise { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..d2a3e20 --- /dev/null +++ b/src/crypto.ts @@ -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; + 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 | 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(), +); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..701e0f9 --- /dev/null +++ b/src/errors.ts @@ -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; + + constructor( + statusCode: number, + code: ErrorCode, + message: string, + requestId: string, + details?: Record, + ) { + super(message); + this.name = 'ElydoraError'; + this.statusCode = statusCode; + this.code = code; + this.requestId = requestId; + this.details = details; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2cba65b --- /dev/null +++ b/src/index.ts @@ -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'; diff --git a/src/plugins/augment.ts b/src/plugins/augment.ts new file mode 100644 index 0000000..1919f81 --- /dev/null +++ b/src/plugins/augment.ts @@ -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>): Array> { + return arr.filter((entry) => { + if (Array.isArray(entry.hooks)) { + const cmds = entry.hooks as Array>; + 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 { + const configDir = resolveConfigDir(); + await fsp.mkdir(configDir, { recursive: true }); + + const configPath = resolveConfigPath(); + let settings: Record = {}; + + 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; + + // --- PreToolUse (guard — freeze enforcement) --- + if (!Array.isArray(hooks.PreToolUse)) { + hooks.PreToolUse = []; + } + const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array>); + 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>); + 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 { + const configPath = resolveConfigPath(); + + let settings: Record; + try { + const raw = await fsp.readFile(configPath, 'utf-8'); + settings = JSON.parse(raw); + } catch { + return; + } + + const hooks = settings.hooks as Record | undefined; + if (!hooks) return; + + if (Array.isArray(hooks.PreToolUse)) { + hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array>); + } + if (Array.isArray(hooks.PostToolUse)) { + hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array>); + } + + await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + }, + + async status(): Promise { + 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 | undefined; + if (hooks) { + const checkArr = (arr: unknown) => { + if (!Array.isArray(arr)) return false; + return (arr as Array>).some((entry) => { + if (Array.isArray(entry.hooks)) { + return (entry.hooks as Array>).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, + }; + }, +}; diff --git a/src/plugins/base.ts b/src/plugins/base.ts new file mode 100644 index 0000000..2ad3666 --- /dev/null +++ b/src/plugins/base.ts @@ -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; + uninstall(): Promise; + status(): Promise; +} diff --git a/src/plugins/claudecode.ts b/src/plugins/claudecode.ts new file mode 100644 index 0000000..1cbf6e6 --- /dev/null +++ b/src/plugins/claudecode.ts @@ -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>): Array> { + return arr.filter((entry) => { + if (Array.isArray(entry.hooks)) { + const cmds = entry.hooks as Array>; + 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 { + const configDir = resolveConfigDir(); + await fsp.mkdir(configDir, { recursive: true }); + + const configPath = resolveConfigPath(); + let settings: Record = {}; + + 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; + + // --- PreToolUse (guard — freeze enforcement) --- + if (!Array.isArray(hooks.PreToolUse)) { + hooks.PreToolUse = []; + } + const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array>); + 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>); + 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 { + const configPath = resolveConfigPath(); + + let settings: Record; + try { + const raw = await fsp.readFile(configPath, 'utf-8'); + settings = JSON.parse(raw); + } catch { + return; // Nothing to uninstall + } + + const hooks = settings.hooks as Record | undefined; + if (!hooks) return; + + if (Array.isArray(hooks.PreToolUse)) { + hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array>); + } + if (Array.isArray(hooks.PostToolUse)) { + hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array>); + } + + await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + }, + + async status(): Promise { + 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 | undefined; + if (hooks) { + const checkArr = (arr: unknown) => { + if (!Array.isArray(arr)) return false; + return (arr as Array>).some((entry) => { + if (Array.isArray(entry.hooks)) { + return (entry.hooks as Array>).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, + }; + }, +}; diff --git a/src/plugins/cursor.ts b/src/plugins/cursor.ts new file mode 100644 index 0000000..a51fa24 --- /dev/null +++ b/src/plugins/cursor.ts @@ -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>): Array> { + return arr.filter( + (h) => typeof h.command === 'string' && !h.command.includes('elydora'), + ); +} + +export const cursorPlugin: AgentPlugin = { + async install(config: InstallConfig): Promise { + const configDir = resolveConfigDir(); + await fsp.mkdir(configDir, { recursive: true }); + + const configPath = resolveConfigPath(); + let settings: Record = {}; + + 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; + + // --- preToolUse (guard — freeze enforcement) --- + if (!Array.isArray(hooks.preToolUse)) { + hooks.preToolUse = []; + } + const preFiltered = filterElydoraEntries(hooks.preToolUse as Array>); + 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>); + 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 { + const configPath = resolveConfigPath(); + + let settings: Record; + try { + const raw = await fsp.readFile(configPath, 'utf-8'); + settings = JSON.parse(raw); + } catch { + return; + } + + const hooks = settings.hooks as Record | undefined; + if (!hooks) return; + + if (Array.isArray(hooks.preToolUse)) { + hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array>); + } + if (Array.isArray(hooks.postToolUse)) { + hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array>); + } + + await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + }, + + async status(): Promise { + 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 | undefined; + if (hooks) { + const check = (arr: unknown) => + Array.isArray(arr) && (arr as Array>).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, + }; + }, +}; diff --git a/src/plugins/gemini.ts b/src/plugins/gemini.ts new file mode 100644 index 0000000..256a561 --- /dev/null +++ b/src/plugins/gemini.ts @@ -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>): Array> { + return arr.filter((entry) => { + if (Array.isArray(entry.hooks)) { + const cmds = entry.hooks as Array>; + 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 { + const configDir = resolveConfigDir(); + await fsp.mkdir(configDir, { recursive: true }); + + const configPath = resolveConfigPath(); + let settings: Record = {}; + + 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; + + // --- BeforeTool (guard — freeze enforcement) --- + if (!Array.isArray(hooks.BeforeTool)) { + hooks.BeforeTool = []; + } + const preFiltered = filterElydoraEntries(hooks.BeforeTool as Array>); + 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>); + 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 { + const configPath = resolveConfigPath(); + + let settings: Record; + try { + const raw = await fsp.readFile(configPath, 'utf-8'); + settings = JSON.parse(raw); + } catch { + return; + } + + const hooks = settings.hooks as Record | undefined; + if (!hooks) return; + + if (Array.isArray(hooks.BeforeTool)) { + hooks.BeforeTool = filterElydoraEntries(hooks.BeforeTool as Array>); + } + if (Array.isArray(hooks.AfterTool)) { + hooks.AfterTool = filterElydoraEntries(hooks.AfterTool as Array>); + } + + await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + }, + + async status(): Promise { + 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 | undefined; + if (hooks) { + const checkArr = (arr: unknown) => { + if (!Array.isArray(arr)) return false; + return (arr as Array>).some((entry) => { + if (Array.isArray(entry.hooks)) { + return (entry.hooks as Array>).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, + }; + }, +}; diff --git a/src/plugins/hook-template.ts b/src/plugins/hook-template.ts new file mode 100644 index 0000000..ef2e7a4 --- /dev/null +++ b/src/plugins/hook-template.ts @@ -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(); +`; +} diff --git a/src/plugins/kiro.ts b/src/plugins/kiro.ts new file mode 100644 index 0000000..e14579a --- /dev/null +++ b/src/plugins/kiro.ts @@ -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 { + 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 { + const configPath = resolveConfigPath(); + try { + await fsp.unlink(configPath); + } catch { + // Already removed + } + }, + + async status(): Promise { + 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, + }; + }, +}; diff --git a/src/plugins/opencode.ts b/src/plugins/opencode.ts new file mode 100644 index 0000000..cb06f76 --- /dev/null +++ b/src/plugins/opencode.ts @@ -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 { + 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 { + const configPath = resolveConfigPath(); + try { + await fsp.unlink(configPath); + } catch { + // Already removed + } + }, + + async status(): Promise { + 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, + }; + }, +}; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts new file mode 100644 index 0000000..584cbef --- /dev/null +++ b/src/plugins/registry.ts @@ -0,0 +1,14 @@ +export interface AgentRegistryEntry { + readonly name: string; + readonly configDir: string; + readonly configFile: string; +} + +export const SUPPORTED_AGENTS: ReadonlyMap = 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' }], +]); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a009ba0 --- /dev/null +++ b/src/types.ts @@ -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; + readonly action: Record; + readonly payload: Record | 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; + }; +} + +// --------------------------------------------------------------------------- +// 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; + readonly action: Record; + readonly payload?: Record | string | null; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..4394f3f --- /dev/null +++ b/src/utils.ts @@ -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'); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3b4d664 --- /dev/null +++ b/tsconfig.json @@ -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"] +}