fix: make Codex hooks transactional
This commit is contained in:
1 parent
415a884efe
commit
f0e4694da2
17 files changed
+2550
-506
No files matched your search
@@ -2,10 +2,7 @@ package plugins
|
||||
|
||||
import "fmt"
|
||||
|
||||
// GenerateGuardScript returns a self-contained Node.js guard script that checks
|
||||
// whether the agent is frozen and blocks tool execution if so.
|
||||
// It caches the agent status locally (60s TTL) to avoid hitting the API on every
|
||||
// tool call, and fails open if the API is unreachable or config is missing.
|
||||
// GenerateGuardScript returns a self-contained agent status guard.
|
||||
func GenerateGuardScript(agentName string, agentID string) string {
|
||||
return generateGuardScript(agentName, agentID, "", false)
|
||||
}
|
||||
@@ -14,11 +11,8 @@ func generateGuardScript(agentName, agentID, successOutput string, failClosed bo
|
||||
return fmt.Sprintf(`#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elydora agent guard (PreToolUse) for: %s
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Elydora agent guard for %s. Generated by Elydora SDK.
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
@@ -34,105 +28,197 @@ const CACHE_TTL_MS = 60000;
|
||||
|
||||
%s
|
||||
|
||||
process.stdin.resume();
|
||||
process.stdin.on('data', () => {});
|
||||
function validateStatus(value, source) {
|
||||
if (!['active', 'frozen', 'revoked'].includes(value)) {
|
||||
throw new Error(source + ' contains an invalid agent status');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function blockAgent(status) {
|
||||
const state = status === 'frozen' ? 'is frozen by Elydora' : 'is revoked in Elydora';
|
||||
process.stderr.write(
|
||||
'Agent "' + AGENT_NAME + '" ' + state + '. Tool execution blocked.\n',
|
||||
);
|
||||
const message = 'Agent "' + AGENT_NAME + '" ' + state + '.';
|
||||
if (SUCCESS_OUTPUT) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
permission: 'deny',
|
||||
userMessage: message,
|
||||
agentMessage: message + ' Tool execution is blocked.',
|
||||
}) + '\n');
|
||||
}
|
||||
process.stderr.write('[Elydora guard] ' + message + ' Tool execution blocked.\n');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(readProtectedFile(
|
||||
CONFIG_PATH,
|
||||
'Agent config',
|
||||
MAX_PROTECTED_CONFIG_BYTES,
|
||||
).toString('utf-8'));
|
||||
} catch (err) {
|
||||
if (FAIL_CLOSED) {
|
||||
throw new Error('Failed to read agent config: ' + err.message);
|
||||
}
|
||||
process.stderr.write('[Elydora guard] Failed to read agent config: ' + err.message + '\n');
|
||||
async function readInput() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
||||
if (!raw) {
|
||||
if (FAIL_CLOSED) throw new Error('Hook input is empty');
|
||||
process.stderr.write(
|
||||
'[Elydora guard] Hook input is empty; enforcement continued in fail-open mode.\n',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let value;
|
||||
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 (!['active', 'frozen', 'revoked'].includes(cache.status)) {
|
||||
throw new Error('Status cache contains an invalid status');
|
||||
}
|
||||
if (cache.status !== 'active') {
|
||||
blockAgent(cache.status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
if (FAIL_CLOSED) {
|
||||
throw new Error('Failed to read status cache: ' + err.message);
|
||||
}
|
||||
process.stderr.write('[Elydora guard] Failed to read status cache: ' + err.message + '\n');
|
||||
}
|
||||
value = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
if (FAIL_CLOSED) throw new Error('Hook input is invalid JSON: ' + error.message);
|
||||
process.stderr.write(
|
||||
'[Elydora guard] Hook input is invalid JSON; ' +
|
||||
'enforcement continued in fail-open mode: ' + error.message + '\n',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
if (FAIL_CLOSED) throw new Error('Hook input must contain a JSON object');
|
||||
process.stderr.write(
|
||||
'[Elydora guard] Hook input must contain a JSON object; ' +
|
||||
'enforcement continued in fail-open mode.\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const baseURL = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, '');
|
||||
function readConfig() {
|
||||
const raw = readProtectedFile(
|
||||
CONFIG_PATH,
|
||||
'Agent config',
|
||||
MAX_PROTECTED_CONFIG_BYTES,
|
||||
).toString('utf-8');
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error('Agent config is invalid JSON: ' + error.message);
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
throw new Error('Agent config must contain a JSON object');
|
||||
}
|
||||
if (config.agent_id !== AGENT_ID ||
|
||||
(config.agent_name !== undefined && config.agent_name !== AGENT_NAME)) {
|
||||
throw new Error('Agent config identity does not match the generated guard');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function readStatusCache() {
|
||||
let raw;
|
||||
try {
|
||||
raw = readProtectedFile(
|
||||
STATUS_CACHE_PATH,
|
||||
'Status cache',
|
||||
MAX_PROTECTED_CONFIG_BYTES,
|
||||
).toString('utf-8');
|
||||
} catch (error) {
|
||||
if (hasRuntimeErrorCode(error, 'ENOENT')) return undefined;
|
||||
throw new Error('Failed to read status cache: ' + error.message);
|
||||
}
|
||||
let cache;
|
||||
try {
|
||||
cache = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error('Status cache is invalid JSON: ' + error.message);
|
||||
}
|
||||
if (!cache || typeof cache !== 'object' || Array.isArray(cache) ||
|
||||
!Number.isFinite(cache.cached_at)) {
|
||||
throw new Error('Status cache has an invalid shape');
|
||||
}
|
||||
const age = Date.now() - cache.cached_at;
|
||||
if (age < 0) throw new Error('Status cache timestamp is in the future');
|
||||
return { status: validateStatus(cache.status, 'Status cache'), age };
|
||||
}
|
||||
|
||||
function writeStatusCache(status) {
|
||||
writeProtectedJson(STATUS_CACHE_PATH, 'Status cache', {
|
||||
status: status,
|
||||
cached_at: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBaseURL(config) {
|
||||
const value = config.base_url || 'https://api.elydora.com';
|
||||
if (typeof value !== 'string' || /[\\\u0000-\u0020]/.test(value)) {
|
||||
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch (error) {
|
||||
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
||||
}
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
|
||||
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
||||
}
|
||||
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
||||
throw new Error(
|
||||
'Agent config base_url must exclude credentials, query parameters, and fragments',
|
||||
);
|
||||
}
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async function resolveRemoteStatus(config) {
|
||||
const baseURL = resolveBaseURL(config);
|
||||
const token = config.token || '';
|
||||
const agentID = config.agent_id;
|
||||
if (typeof token !== 'string') throw new Error('Agent config token must be a string');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
try {
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
const response = await fetch(baseURL + '/v1/agents/' + encodeURIComponent(agentID), {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (token) headers.Authorization = 'Bearer ' + token;
|
||||
const response = await fetch(baseURL + '/v1/agents/' + encodeURIComponent(AGENT_ID), {
|
||||
headers: headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Agent status API returned HTTP ' + response.status);
|
||||
}
|
||||
if (!response.ok) throw new Error('Agent status API returned HTTP ' + response.status);
|
||||
const data = await response.json();
|
||||
const status = data.agent && data.agent.status;
|
||||
if (!['active', 'frozen', 'revoked'].includes(status)) {
|
||||
throw new Error('Agent status API returned an invalid status');
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({
|
||||
status: status,
|
||||
cached_at: Date.now(),
|
||||
}), 'utf-8');
|
||||
} catch (err) {
|
||||
process.stderr.write('[Elydora guard] Failed to update status cache: ' + err.message + '\n');
|
||||
}
|
||||
if (status !== 'active') {
|
||||
blockAgent(status);
|
||||
}
|
||||
} catch (err) {
|
||||
if (FAIL_CLOSED) {
|
||||
throw new Error('Failed to resolve agent status: ' + err.message);
|
||||
}
|
||||
process.stderr.write('[Elydora guard] Failed to resolve agent status: ' + err.message + '\n');
|
||||
return validateStatus(data && data.agent && data.agent.status, 'Agent status API');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await readInput();
|
||||
let config;
|
||||
try {
|
||||
config = readConfig();
|
||||
} catch (error) {
|
||||
if (FAIL_CLOSED) throw new Error('Failed to read agent config: ' + error.message);
|
||||
process.stderr.write('[Elydora guard] Failed to read agent config: ' + error.message + '\n');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cache = readStatusCache();
|
||||
if (cache && cache.age < CACHE_TTL_MS) {
|
||||
if (cache.status !== 'active') blockAgent(cache.status);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (FAIL_CLOSED) throw error;
|
||||
process.stderr.write('[Elydora guard] ' + error.message + '\n');
|
||||
}
|
||||
try {
|
||||
const status = await resolveRemoteStatus(config);
|
||||
writeStatusCache(status);
|
||||
if (status !== 'active') blockAgent(status);
|
||||
} catch (error) {
|
||||
if (FAIL_CLOSED) throw new Error('Failed to resolve agent status: ' + error.message);
|
||||
process.stderr.write(
|
||||
'[Elydora guard] Failed to resolve agent status: ' + error.message + '\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().then(() => {
|
||||
if ((!process.exitCode || process.exitCode === 0) && SUCCESS_OUTPUT) {
|
||||
process.stdout.write(SUCCESS_OUTPUT);
|
||||
}
|
||||
}).catch((err) => {
|
||||
process.stderr.write('[Elydora guard] ' + (err.stack || err.message || String(err)) + '\n');
|
||||
}).catch((error) => {
|
||||
process.stderr.write(
|
||||
'[Elydora guard] ' + (error.stack || error.message || String(error)) + '\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
`, agentName, agentName, agentID, successOutput, failClosed, protectedRuntimeFileReader)
|
||||
|
||||
Reference in new issue
Block a user