Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2101cb77e3 | ||
|
|
85c2d2d491 |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@elydora/sdk",
|
"name": "@elydora/sdk",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@elydora/sdk",
|
"name": "@elydora/sdk",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@decimalturn/toml-patch": "^2.0.0",
|
"@decimalturn/toml-patch": "^2.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elydora/sdk",
|
"name": "@elydora/sdk",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"description": "Node.js/TypeScript SDK for the Elydora tamper-evident audit platform",
|
"description": "Node.js/TypeScript SDK for the Elydora tamper-evident audit platform",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
+145
-51
@@ -38,6 +38,8 @@ const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key');
|
|||||||
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json');
|
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json');
|
||||||
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log');
|
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log');
|
||||||
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||||
|
const MAX_CHAIN_ATTEMPTS = 5;
|
||||||
|
const SUBMIT_BUDGET_MS = 4000;
|
||||||
|
|
||||||
${PROTECTED_RUNTIME_READER}
|
${PROTECTED_RUNTIME_READER}
|
||||||
|
|
||||||
@@ -165,6 +167,81 @@ function writeChainState(chainHash) {
|
|||||||
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
|
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lockOwnerAlive(lockPath) {
|
||||||
|
let owner;
|
||||||
|
try {
|
||||||
|
owner = Number.parseInt(fs.readFileSync(lockPath, 'utf-8'), 10);
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(owner) || owner <= 0) return false;
|
||||||
|
try {
|
||||||
|
process.kill(owner, 0);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (hasRuntimeErrorCode(error, 'ESRCH')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withChainLock(update) {
|
||||||
|
const lockPath = CHAIN_STATE_PATH + '.lock';
|
||||||
|
const deadline = performance.now() + 1000;
|
||||||
|
for (;;) {
|
||||||
|
let descriptor;
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(lockPath, 'wx', 0o600);
|
||||||
|
fs.writeSync(descriptor, String(process.pid));
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasRuntimeErrorCode(error, 'EEXIST')) throw error;
|
||||||
|
if (lockOwnerAlive(lockPath)) {
|
||||||
|
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let age = 0;
|
||||||
|
try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch (statError) {
|
||||||
|
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
|
||||||
|
}
|
||||||
|
if (age > 5000) {
|
||||||
|
const reclaimed = lockPath + '.' + process.pid + '.stale';
|
||||||
|
try {
|
||||||
|
fs.renameSync(lockPath, reclaimed);
|
||||||
|
fs.unlinkSync(reclaimed);
|
||||||
|
} catch (reclaimError) {
|
||||||
|
if (!hasRuntimeErrorCode(reclaimError, 'ENOENT')) throw reclaimError;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return update();
|
||||||
|
} finally {
|
||||||
|
let current = null;
|
||||||
|
try {
|
||||||
|
current = fs.lstatSync(lockPath);
|
||||||
|
} catch (statError) {
|
||||||
|
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
|
||||||
|
}
|
||||||
|
const own = fs.fstatSync(descriptor);
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
if (current && current.ino === own.ino && current.dev === own.dev) fs.unlinkSync(lockPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceChainState(fromHash, toHash) {
|
||||||
|
return withChainLock(() => {
|
||||||
|
if (readChainState() !== fromHash) return false;
|
||||||
|
writeChainState(toHash);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function readConfigAndKey() {
|
function readConfigAndKey() {
|
||||||
const raw = readProtectedFile(
|
const raw = readProtectedFile(
|
||||||
CONFIG_PATH,
|
CONFIG_PATH,
|
||||||
@@ -219,27 +296,43 @@ async function readHookInput() {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitOperation(hookData, runtime) {
|
async function postOperation(operation, runtime, deadline) {
|
||||||
const toolEvent = hookData.tool_result && typeof hookData.tool_result === 'object'
|
const remaining = deadline - performance.now();
|
||||||
? hookData.tool_result
|
if (remaining <= 0) throw new Error('Audit API retry budget exhausted');
|
||||||
: hookData.tool_call && typeof hookData.tool_call === 'object'
|
const controller = new AbortController();
|
||||||
? hookData.tool_call
|
const timeout = setTimeout(() => controller.abort(), Math.min(5000, remaining));
|
||||||
: {};
|
try {
|
||||||
const toolName = hookData.tool_name || hookData.toolName || hookData.name ||
|
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||||
toolEvent.name || 'unknown';
|
if (runtime.config.token) headers.Authorization = 'Bearer ' + runtime.config.token;
|
||||||
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input ||
|
const response = await fetch(runtime.baseUrl + '/v1/operations', {
|
||||||
hookData.parameters || toolEvent.input || {};
|
method: 'POST',
|
||||||
const sessionId = hookData.conversation_id || hookData.session_id || hookData.sessionId ||
|
headers,
|
||||||
hookData.session || hookData.taskId || 'unknown';
|
body: JSON.stringify(operation),
|
||||||
const previousHash = readChainState();
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (response.ok) return { accepted: true };
|
||||||
|
const responseBody = await response.text();
|
||||||
|
if (response.status === 400 && responseBody) {
|
||||||
|
let failure;
|
||||||
|
try { failure = JSON.parse(responseBody); } catch (error) {
|
||||||
|
throw new Error('Audit API returned invalid error JSON: ' + error.message);
|
||||||
|
}
|
||||||
|
if (failure.error && failure.error.code === 'PREV_HASH_MISMATCH') {
|
||||||
|
const match = String(failure.error.message || '').match(
|
||||||
|
/Expected prev_chain_hash "([A-Za-z0-9_-]{43})"/,
|
||||||
|
);
|
||||||
|
if (match) return { accepted: false, expected: match[1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Audit API returned HTTP ' + response.status);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOperation(runtime, payload, payloadHash, toolName, sessionId, previousHash) {
|
||||||
const operationId = uuidv7();
|
const operationId = uuidv7();
|
||||||
const issuedAt = Date.now();
|
const issuedAt = Date.now();
|
||||||
const payload = NATIVE_PAYLOAD ? hookData : {
|
|
||||||
tool_name: toolName,
|
|
||||||
tool_input: toolInput,
|
|
||||||
session_id: sessionId,
|
|
||||||
};
|
|
||||||
const payloadHash = computePayloadHash(payload);
|
|
||||||
const chainHash = computeChainHash(previousHash, payloadHash, operationId, issuedAt);
|
const chainHash = computeChainHash(previousHash, payloadHash, operationId, issuedAt);
|
||||||
const unsigned = {
|
const unsigned = {
|
||||||
op_version: '1.0',
|
op_version: '1.0',
|
||||||
@@ -261,41 +354,42 @@ async function submitOperation(hookData, runtime) {
|
|||||||
runtime.privateKey,
|
runtime.privateKey,
|
||||||
Buffer.from(jcsCanonicalise(unsigned), 'utf-8'),
|
Buffer.from(jcsCanonicalise(unsigned), 'utf-8'),
|
||||||
);
|
);
|
||||||
const operation = { ...unsigned, chain_hash: chainHash, signature };
|
return { operation: { ...unsigned, chain_hash: chainHash, signature }, chainHash };
|
||||||
const controller = new AbortController();
|
}
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
||||||
try {
|
async function submitOperation(hookData, runtime) {
|
||||||
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
const toolEvent = hookData.tool_result && typeof hookData.tool_result === 'object'
|
||||||
if (runtime.config.token) headers.Authorization = 'Bearer ' + runtime.config.token;
|
? hookData.tool_result
|
||||||
const response = await fetch(runtime.baseUrl + '/v1/operations', {
|
: hookData.tool_call && typeof hookData.tool_call === 'object'
|
||||||
method: 'POST',
|
? hookData.tool_call
|
||||||
headers,
|
: {};
|
||||||
body: JSON.stringify(operation),
|
const toolName = hookData.tool_name || hookData.toolName || hookData.name ||
|
||||||
signal: controller.signal,
|
toolEvent.name || 'unknown';
|
||||||
});
|
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input ||
|
||||||
if (response.ok) {
|
hookData.parameters || toolEvent.input || {};
|
||||||
writeChainState(chainHash);
|
const sessionId = hookData.conversation_id || hookData.session_id || hookData.sessionId ||
|
||||||
|
hookData.session || hookData.taskId || 'unknown';
|
||||||
|
const payload = NATIVE_PAYLOAD ? hookData : {
|
||||||
|
tool_name: toolName,
|
||||||
|
tool_input: toolInput,
|
||||||
|
session_id: sessionId,
|
||||||
|
};
|
||||||
|
const payloadHash = computePayloadHash(payload);
|
||||||
|
const deadline = performance.now() + SUBMIT_BUDGET_MS;
|
||||||
|
let previousHash = readChainState();
|
||||||
|
for (let attempt = 1; ; attempt += 1) {
|
||||||
|
const built = buildOperation(runtime, payload, payloadHash, toolName, sessionId, previousHash);
|
||||||
|
const result = await postOperation(built.operation, runtime, deadline);
|
||||||
|
if (result.accepted) {
|
||||||
|
advanceChainState(previousHash, built.chainHash);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const responseBody = await response.text();
|
advanceChainState(previousHash, result.expected);
|
||||||
if (response.status === 400 && responseBody) {
|
logError(new Error('Chain hash resynced to server: ' + result.expected));
|
||||||
let failure;
|
if (attempt >= MAX_CHAIN_ATTEMPTS) {
|
||||||
try { failure = JSON.parse(responseBody); } catch (error) {
|
throw new Error('Audit API rejected prev_chain_hash ' + attempt + ' times');
|
||||||
throw new Error('Audit API returned invalid error JSON: ' + error.message);
|
|
||||||
}
|
|
||||||
if (failure.error && failure.error.code === 'PREV_HASH_MISMATCH') {
|
|
||||||
const match = String(failure.error.message || '').match(
|
|
||||||
/Expected prev_chain_hash "([A-Za-z0-9_-]{43})"/,
|
|
||||||
);
|
|
||||||
if (match) {
|
|
||||||
writeChainState(match[1]);
|
|
||||||
logError(new Error('Chain hash resynced to server: ' + match[1]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
throw new Error('Audit API returned HTTP ' + response.status);
|
previousHash = readChainState();
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync, utimesSync, writeFileSync } from 'node:fs';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import http from 'node:http';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
@@ -143,3 +145,196 @@ test('Claude runtime failures stay observable while guard and audit delivery rem
|
|||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function mismatchServer(respond) {
|
||||||
|
const submissions = [];
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
let raw = '';
|
||||||
|
request.on('data', (chunk) => { raw += chunk; });
|
||||||
|
request.on('end', () => {
|
||||||
|
if (request.url !== '/v1/operations') {
|
||||||
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ agent: { status: 'active' } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submissions.push(JSON.parse(raw));
|
||||||
|
const reply = respond(submissions) ?? mismatch(String(submissions.length).padStart(43, 'A'), 'x');
|
||||||
|
const send = () => {
|
||||||
|
response.writeHead(reply.status, { 'Content-Type': 'application/json' });
|
||||||
|
response.end(JSON.stringify(reply.body));
|
||||||
|
};
|
||||||
|
if (api.slow) setTimeout(send, 2600); else send();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const api = { submissions, slow: false };
|
||||||
|
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(Object.assign(api, {
|
||||||
|
baseUrl: 'http://127.0.0.1:' + server.address().port,
|
||||||
|
close: () => new Promise((done) => { server.closeAllConnections?.(); server.close(done); }),
|
||||||
|
}))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mismatch(expected, actual) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: { error: { code: 'PREV_HASH_MISMATCH', message: 'Expected prev_chain_hash "' + expected + '", got "' + actual + '".' } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Claude audit retries a rejected prev_chain_hash with the server value', async () => {
|
||||||
|
const expected = 'Rxlf4j36C3KvIQ3hWuOkX698BR5iDypUFuB70JjEuvM';
|
||||||
|
const api = await mismatchServer((submissions) => (submissions.length === 1
|
||||||
|
? mismatch(expected, submissions[0].prev_chain_hash)
|
||||||
|
: { status: 202, body: { receipt: { seq_no: 2 } } }));
|
||||||
|
const fixture = await createFixture({ baseUrl: api.baseUrl });
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const { settings } = await readSettings(fixture.settingsPath);
|
||||||
|
const audit = managedHandler(settings, 'PostToolUse');
|
||||||
|
const result = await runClaudeHook(
|
||||||
|
audit,
|
||||||
|
JSON.stringify(officialPayload('PostToolUse', { tool_response: { stdout: 'ok' } })),
|
||||||
|
fixture,
|
||||||
|
);
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
assert.equal(api.submissions.length, 2);
|
||||||
|
assert.equal(api.submissions[1].prev_chain_hash, expected);
|
||||||
|
assert.notEqual(api.submissions[1].operation_id, api.submissions[0].operation_id);
|
||||||
|
assert.notEqual(api.submissions[1].nonce, api.submissions[0].nonce);
|
||||||
|
const state = JSON.parse(await readFile(path.join(fixture.agentDir, 'chain-state.json'), 'utf-8'));
|
||||||
|
assert.equal(state.prev_chain_hash, api.submissions[1].chain_hash);
|
||||||
|
assert.match(await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), /resynced to server: Rxlf/);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
await api.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Claude audit stops after five rejected prev_chain_hash attempts', async () => {
|
||||||
|
const api = await mismatchServer((submissions) => (
|
||||||
|
mismatch(String(submissions.length).padStart(43, 'A'), submissions.at(-1).prev_chain_hash)
|
||||||
|
));
|
||||||
|
const fixture = await createFixture({ baseUrl: api.baseUrl });
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const { settings } = await readSettings(fixture.settingsPath);
|
||||||
|
const audit = managedHandler(settings, 'PostToolUse');
|
||||||
|
const result = await runClaudeHook(
|
||||||
|
audit,
|
||||||
|
JSON.stringify(officialPayload('PostToolUse', { tool_response: { stdout: 'ok' } })),
|
||||||
|
fixture,
|
||||||
|
);
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
assert.equal(api.submissions.length, 5);
|
||||||
|
assert.equal(api.submissions[4].prev_chain_hash, '4'.padStart(43, 'A'));
|
||||||
|
assert.match(await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), /rejected prev_chain_hash 5 times/);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
await api.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Claude audit stops retrying when the submit budget is spent', async () => {
|
||||||
|
const api = await mismatchServer(() => null);
|
||||||
|
api.slow = true;
|
||||||
|
const fixture = await createFixture({ baseUrl: api.baseUrl });
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const { settings } = await readSettings(fixture.settingsPath);
|
||||||
|
const audit = managedHandler(settings, 'PostToolUse');
|
||||||
|
const started = Date.now();
|
||||||
|
const result = await runClaudeHook(
|
||||||
|
audit,
|
||||||
|
JSON.stringify(officialPayload('PostToolUse', { tool_response: { stdout: 'ok' } })),
|
||||||
|
fixture,
|
||||||
|
);
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
assert.ok(elapsed < 7000, `hook ran ${elapsed}ms`);
|
||||||
|
assert.ok(api.submissions.length >= 2 && api.submissions.length <= 3, `${api.submissions.length} submissions`);
|
||||||
|
assert.match(await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), /aborted|retry budget exhausted/i);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
await api.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Claude audit does not regress a chain head advanced by a concurrent hook', async () => {
|
||||||
|
const newerHead = 'B'.repeat(43);
|
||||||
|
let statePath;
|
||||||
|
const api = await mismatchServer(() => {
|
||||||
|
writeFileSync(statePath, JSON.stringify({ prev_chain_hash: newerHead }), { mode: 0o600 });
|
||||||
|
return { status: 202, body: { receipt: { seq_no: 1 } } };
|
||||||
|
});
|
||||||
|
const fixture = await createFixture({ baseUrl: api.baseUrl });
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
statePath = path.join(fixture.agentDir, 'chain-state.json');
|
||||||
|
const { settings } = await readSettings(fixture.settingsPath);
|
||||||
|
const audit = managedHandler(settings, 'PostToolUse');
|
||||||
|
const result = await runClaudeHook(
|
||||||
|
audit,
|
||||||
|
JSON.stringify(officialPayload('PostToolUse', { tool_response: { stdout: 'ok' } })),
|
||||||
|
fixture,
|
||||||
|
);
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
assert.equal(api.submissions[0].prev_chain_hash, 'A'.repeat(43));
|
||||||
|
const state = JSON.parse(await readFile(statePath, 'utf-8'));
|
||||||
|
assert.equal(state.prev_chain_hash, newerHead);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
await api.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Claude audit clears a stale chain-state lock and proceeds', async () => {
|
||||||
|
const api = await mismatchServer(() => ({ status: 202, body: { receipt: { seq_no: 1 } } }));
|
||||||
|
const fixture = await createFixture({ baseUrl: api.baseUrl });
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const lockPath = path.join(fixture.agentDir, 'chain-state.json.lock');
|
||||||
|
writeFileSync(lockPath, '', { mode: 0o600 });
|
||||||
|
const stale = new Date(Date.now() - 10_000);
|
||||||
|
utimesSync(lockPath, stale, stale);
|
||||||
|
const { settings } = await readSettings(fixture.settingsPath);
|
||||||
|
const audit = managedHandler(settings, 'PostToolUse');
|
||||||
|
const result = await runClaudeHook(
|
||||||
|
audit,
|
||||||
|
JSON.stringify(officialPayload('PostToolUse', { tool_response: { stdout: 'ok' } })),
|
||||||
|
fixture,
|
||||||
|
);
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
assert.equal(api.submissions.length, 1);
|
||||||
|
assert.equal(existsSync(lockPath), false);
|
||||||
|
const state = JSON.parse(await readFile(path.join(fixture.agentDir, 'chain-state.json'), 'utf-8'));
|
||||||
|
assert.equal(state.prev_chain_hash, api.submissions[0].chain_hash);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
await api.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Claude audit does not reclaim a chain-state lock whose owner is alive', async () => {
|
||||||
|
const api = await mismatchServer(() => ({ status: 202, body: { receipt: { seq_no: 1 } } }));
|
||||||
|
const fixture = await createFixture({ baseUrl: api.baseUrl });
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const lockPath = path.join(fixture.agentDir, 'chain-state.json.lock');
|
||||||
|
writeFileSync(lockPath, String(process.pid), { mode: 0o600 });
|
||||||
|
const stale = new Date(Date.now() - 10_000);
|
||||||
|
utimesSync(lockPath, stale, stale);
|
||||||
|
const { settings } = await readSettings(fixture.settingsPath);
|
||||||
|
const audit = managedHandler(settings, 'PostToolUse');
|
||||||
|
const result = await runClaudeHook(
|
||||||
|
audit,
|
||||||
|
JSON.stringify(officialPayload('PostToolUse', { tool_response: { stdout: 'ok' } })),
|
||||||
|
fixture,
|
||||||
|
);
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
assert.equal(api.submissions.length, 1);
|
||||||
|
assert.equal(existsSync(lockPath), true);
|
||||||
|
assert.match(await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), /lock timed out/);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
await api.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user