fix(signer): withhold a signature when its audit record fails to write
Hard requirement for this task: audit.ts's append() throws by design on a
write failure (see its own CONTRACT note). The trap is catching that throw
and still returning the signature already produced - that reproduces exactly
the failure the throw exists to prevent, one call site later: a real
signature reaches the caller with no durable record of it, so a restarted
process's committedTodayLovelace() silently under-counts and the daily limit
stops binding.
vector_signer_sign now wraps both audit.append() calls explicitly:
- Signed path: if the append throws, the signature is withheld. The
function returns before ever reading signed.signedCborHex - the only
`return` that reaches it is the one after a successful append. The
response tells the caller a valid signature WAS produced but could not be
durably recorded, so it is being withheld rather than released.
- Refused path (lower stakes - no funds move either way): if the append
throws, the refusal itself is still delivered with its concrete policy
reason (never silently replaced by a generic disk-error message), plus a
note that this specific refusal could not be recorded, so the caller does
not mistake an incomplete audit trail for a normal one.
Mutation-tested against the brief's own unguarded version (see the task
report for the full transcript): reverting to unwrapped `audit.append()`
calls does NOT leak signed CBOR either way (the throw prevents the function
from ever reaching its return statement) - but it does replace "REFUSED.
<reason>" with the SDK's generic isError conversion of audit.ts's own
message on BOTH paths, so a refusal's own policy reason becomes invisible
to the caller exactly when the audit log is unhealthy. Exactly 2 of 21
tests failed under that mutation, precisely the two targeting this gap, no
collateral.
Edge-case pass added to the smoke test (12 new tests, each on its own
dedicated server instance):
- loadConfig() / the built server's behaviour with no key material
configured at all (clear throw; process exits non-zero and promptly,
never hangs, never writes anything to stdout).
- No mnemonic word or the derived private key appears in the startup
banner or in any tool response across a sweep that includes a real
successful sign.
- decode_transaction never writes an audit entry, even for an in-policy
transaction.
- An undecodable transaction passed to sign() never reaches evaluate(), so
there is nothing to audit yet - by design, pinned rather than assumed.
- get_spend_limits surfaces the concrete refusal reason, not just the
word REFUSED.
- The hard requirement itself: a control test proving the in-policy
fixture genuinely signs under a healthy audit log, then the same
transaction against an unwritable audit log withholding the signature,
then a pin of the (intentional, conservative) consequence that the
withheld signature still counts toward this process's own committed-
today total even though nothing was ever released.
- The refused-path decision under the same unwritable-audit-log condition.
Also reworded decode_transaction's response text ("(read-only)" /
"would be ALLOWED" instead of "(not signed)" / "would be signed") so it
never contains the substring "signed" even when a decoded transaction
happens to be in-policy - matches this same smoke test's own
`!/signed/i.test(text)` check on the brief's decode test, which otherwise
fails against the brief's own unmodified wording on every call.
typecheck 0, test:unit 180/180, builder test:smoke 26/23/0, boundary 4/4,
signer test:smoke:signer 21/21 (9 brief + 12 edge-case pass).
This commit is contained in:
@@ -76,14 +76,36 @@ export function registerSignerTools(server: McpServer, config: SignerConfig): vo
|
||||
const decision = evaluate(tx, ownAddresses, config.limits, audit.committedTodayLovelace());
|
||||
|
||||
if (!decision.allowed) {
|
||||
audit.append({
|
||||
timestamp: new Date().toISOString(),
|
||||
txHash: tx.txHashHex,
|
||||
decision: 'refused',
|
||||
netOutflowLovelace: decision.netOutflowLovelace.toString(),
|
||||
reason: decision.reason,
|
||||
assetMovements: decision.assetMovements.length,
|
||||
});
|
||||
// CONTRACT (see audit.ts's own header comment): append() throws if the
|
||||
// write to disk fails. No funds move on a refusal either way, so this
|
||||
// is lower stakes than the signed branch below — but a refusal whose
|
||||
// audit record silently failed to write would still hand back a
|
||||
// clean-looking "REFUSED. <reason>" with nothing to indicate that the
|
||||
// refusal itself is now missing from the durable log. Caught here (not
|
||||
// left to propagate as a generic isError) specifically so the refusal
|
||||
// text and reason are ALWAYS delivered to the caller regardless of
|
||||
// audit health, with an appended note when the record could not be
|
||||
// written — never silence about it, never a bare unexplained error in
|
||||
// its place.
|
||||
try {
|
||||
audit.append({
|
||||
timestamp: new Date().toISOString(),
|
||||
txHash: tx.txHashHex,
|
||||
decision: 'refused',
|
||||
netOutflowLovelace: decision.netOutflowLovelace.toString(),
|
||||
reason: decision.reason,
|
||||
assetMovements: decision.assetMovements.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[sign] audit append failed for a REFUSED tx ${tx.txHashHex}: ${message}`);
|
||||
return text(
|
||||
`REFUSED. ${decision.reason}\n\n` +
|
||||
`Note: this refusal could NOT be recorded in the local audit log (${message}). ` +
|
||||
`No funds moved and nothing was signed, but the audit trail for this refusal is incomplete ` +
|
||||
`until the log is writable again.`
|
||||
);
|
||||
}
|
||||
return text(`REFUSED. ${decision.reason}`);
|
||||
}
|
||||
|
||||
@@ -94,13 +116,34 @@ export function registerSignerTools(server: McpServer, config: SignerConfig): vo
|
||||
return text(`REFUSED. Signing failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
audit.append({
|
||||
timestamp: new Date().toISOString(),
|
||||
txHash: signed.txHashHex,
|
||||
decision: 'signed',
|
||||
netOutflowLovelace: decision.netOutflowLovelace.toString(),
|
||||
assetMovements: decision.assetMovements.length,
|
||||
});
|
||||
// HARD REQUIREMENT (audit.ts's own CONTRACT note; see also the task
|
||||
// report): append() throws on a write failure, by design. The reflex to
|
||||
// avoid here is `catch { warn(); return signed anyway; }` — that
|
||||
// reproduces exactly the failure the throw exists to prevent, just one
|
||||
// call site later: a real signature reaches the caller while no durable
|
||||
// record of it exists on disk, so the next process's
|
||||
// committedTodayLovelace() silently under-counts and the daily limit
|
||||
// stops binding. `signed.signedCborHex` is therefore not read anywhere
|
||||
// below this catch block — the only `return` that can reach it is the
|
||||
// one after a successful append.
|
||||
try {
|
||||
audit.append({
|
||||
timestamp: new Date().toISOString(),
|
||||
txHash: signed.txHashHex,
|
||||
decision: 'signed',
|
||||
netOutflowLovelace: decision.netOutflowLovelace.toString(),
|
||||
assetMovements: decision.assetMovements.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[sign] withholding a produced signature for tx ${signed.txHashHex}: audit append failed: ${message}`);
|
||||
return text(
|
||||
`REFUSED. A valid signature for this transaction was produced, but it could not be durably ` +
|
||||
`recorded in the local audit log, so it is being withheld rather than returned to you. ` +
|
||||
`No signed transaction has been released to any caller. Detail: ${message}\n\n` +
|
||||
`Check that the audit log path is writable, then retry.`
|
||||
);
|
||||
}
|
||||
|
||||
return text(
|
||||
`# Signed\n\nHash: ${signed.txHashHex}\n` +
|
||||
|
||||
@@ -3,8 +3,12 @@ import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import * as CML from '@anastasia-labs/cardano-multiplatform-lib-nodejs';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { MnemonicKeySource } from '../../src/keysource.ts';
|
||||
import { loadConfig } from '../../src/config.ts';
|
||||
|
||||
const TEST_MNEMONIC =
|
||||
'test walk nut penalty hip pave soap entry language right filter choice';
|
||||
@@ -112,3 +116,411 @@ describe('signer MCP server over stdio', () => {
|
||||
assert.match(text, /could not be decoded|refus/i);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Edge-case pass (beyond the brief) --------------------------------------
|
||||
//
|
||||
// The brief's 9 tests above are a floor. Every previous task in this signer
|
||||
// package shipped at least one defect that produced a plausible-looking
|
||||
// success rather than an error (see .superpowers/sdd/signer-task-*-report.md)
|
||||
// — that same suspicion is applied here, especially to the audit-then-return
|
||||
// ordering in the sign tool, which is THE thing this task's brief calls out
|
||||
// as mattering most: audit.ts's append() throws by design on a write
|
||||
// failure (see the CONTRACT note at the top of audit.ts), and a signature
|
||||
// must never be released to a caller when its own audit record failed to
|
||||
// land durably on disk.
|
||||
//
|
||||
// Each test below spins up its OWN dedicated server (own temp dir, own
|
||||
// mnemonic file, own audit log path) rather than sharing the brief's module-
|
||||
// level `client`/`dir` — the brief's own 9 tests are order-dependent on a
|
||||
// single accumulating audit log, and coupling more tests onto that same
|
||||
// shared state would make failures harder to isolate for no benefit, since
|
||||
// nothing below needs to observe the brief's own tests' history.
|
||||
|
||||
const TEST_IDENTITY = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
|
||||
interface EdgeServer {
|
||||
client: Client;
|
||||
dir: string;
|
||||
stderrChunks: string[];
|
||||
}
|
||||
|
||||
async function spawnSigner(
|
||||
opts: {
|
||||
auditLogPath?: (dir: string) => string;
|
||||
perTx?: string;
|
||||
daily?: string;
|
||||
} = {}
|
||||
): Promise<EdgeServer> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'signer-smoke-edge-'));
|
||||
const mnemonicFile = join(dir, 'mnemonic.txt');
|
||||
writeFileSync(mnemonicFile, TEST_MNEMONIC, { mode: 0o600 });
|
||||
const auditLogPath = opts.auditLogPath ? opts.auditLogPath(dir) : join(dir, 'audit.json');
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: ['packages/signer/build/index.js'],
|
||||
env: {
|
||||
...process.env,
|
||||
VECTOR_SIGNER_MNEMONIC_FILE: mnemonicFile,
|
||||
VECTOR_SIGNER_AUDIT_LOG_PATH: auditLogPath,
|
||||
VECTOR_SIGNER_SPEND_LIMIT_PER_TX: opts.perTx ?? '100000000',
|
||||
VECTOR_SIGNER_SPEND_LIMIT_DAILY: opts.daily ?? '500000000',
|
||||
},
|
||||
// Piped (not the brief's default 'inherit') specifically so the startup
|
||||
// banner can be captured and inspected for leaked key material below.
|
||||
// Per the SDK's own doc comment on StdioClientTransport#stderr, the
|
||||
// PassThrough stream exists immediately after construction, so the
|
||||
// listener below is attached before connect() (which spawns the child)
|
||||
// to avoid losing early output.
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const stderrChunks: string[] = [];
|
||||
transport.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk.toString('utf8')));
|
||||
|
||||
const client = new Client({ name: 'signer-smoke-edge', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
// client.connect() only waits for the MCP initialize handshake on stdout;
|
||||
// stderr is a separate, independently-buffered pipe with no ordering
|
||||
// guarantee relative to stdout at the Node event-loop level, even though
|
||||
// the server writes its banner before it ever starts that handshake. A
|
||||
// short grace period avoids a race between "handshake done" and "banner
|
||||
// chunk delivered" when a test reads stderrChunks immediately afterward.
|
||||
await new Promise((r) => setTimeout(r, 75));
|
||||
return { client, dir, stderrChunks };
|
||||
}
|
||||
|
||||
async function stopSigner(server: EdgeServer): Promise<void> {
|
||||
try {
|
||||
await server.client.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
rmSync(server.dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function callToolText(client: Client, name: string, args: Record<string, unknown> = {}): Promise<string> {
|
||||
const r = await client.callTool({ name, arguments: args });
|
||||
const c = r.content as Array<{ type: string; text?: string }>;
|
||||
return c?.[0]?.text ?? '';
|
||||
}
|
||||
|
||||
// Builds a synthetic transaction whose SOLE output pays `payToAddress`, with
|
||||
// a fee well inside the smoke test's 100/500 AP3X limits. The brief's own
|
||||
// FIXTURE_CBOR is deliberately refused for TEST_MNEMONIC (built for a
|
||||
// different, funded wallet — see the note above the "sign refuses..." test),
|
||||
// so a genuinely in-policy transaction has to be constructed by hand to
|
||||
// exercise the "signed" branch of vector_signer_sign at all.
|
||||
//
|
||||
// Verified against a throwaway probe before use in any test (see the task
|
||||
// report): decodes cleanly with decode.ts, policy.evaluate() returns
|
||||
// allowed:true with netOutflowLovelace equal to feeLovelace alone (the
|
||||
// output returns to its own address, so it counts as change, not outflow),
|
||||
// and signTransaction() accepts it. The referenced input UTXO does not need
|
||||
// to exist on-chain — decode.ts/policy.ts/sign.ts never resolve inputs
|
||||
// against the chain, by design (that is exactly why this signer needs no
|
||||
// network access at all).
|
||||
function buildInPolicyTxCbor(payToAddress: string, feeLovelace = 200_000n, outputLovelace = 5_000_000n): string {
|
||||
const inputTxHash = CML.TransactionHash.from_hex(
|
||||
'e17a2ebab8eae3850f959290041b3bef3f3597584f8cf4a728e14777dddb8c32'
|
||||
);
|
||||
const inputs = CML.TransactionInputList.new();
|
||||
inputs.add(CML.TransactionInput.new(inputTxHash, 0n));
|
||||
|
||||
const outputs = CML.TransactionOutputList.new();
|
||||
outputs.add(
|
||||
CML.TransactionOutput.new(CML.Address.from_bech32(payToAddress), CML.Value.from_coin(outputLovelace))
|
||||
);
|
||||
|
||||
const body = CML.TransactionBody.new(inputs, outputs, feeLovelace);
|
||||
const tx = CML.Transaction.new(body, CML.TransactionWitnessSet.new(), true, undefined);
|
||||
return tx.to_cbor_hex();
|
||||
}
|
||||
|
||||
describe('loadConfig() — no key material configured', () => {
|
||||
test('throws a clear, specific error naming what is missing, not a vague one', () => {
|
||||
// No VECTOR_SIGNER_MNEMONIC / _MNEMONIC_FILE / _PRIVATE_KEY+_ADDRESS at
|
||||
// all — the "nobody configured a key yet" case a fresh deployment or a
|
||||
// misconfigured launcher would actually hit.
|
||||
const bareEnv = {} as NodeJS.ProcessEnv;
|
||||
assert.throws(() => loadConfig(bareEnv), /No key material configured/);
|
||||
});
|
||||
|
||||
test('the built server exits promptly and loudly on stderr rather than hanging or crashing unhelpfully', async () => {
|
||||
const strippedEnv: NodeJS.ProcessEnv = { ...process.env };
|
||||
delete strippedEnv.VECTOR_SIGNER_MNEMONIC;
|
||||
delete strippedEnv.VECTOR_SIGNER_MNEMONIC_FILE;
|
||||
delete strippedEnv.VECTOR_SIGNER_PRIVATE_KEY;
|
||||
delete strippedEnv.VECTOR_SIGNER_ADDRESS;
|
||||
|
||||
const child: ChildProcess = spawn('node', ['packages/signer/build/index.js'], {
|
||||
env: strippedEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout!.on('data', (c: Buffer) => {
|
||||
stdout += c.toString('utf8');
|
||||
});
|
||||
child.stderr!.on('data', (c: Buffer) => {
|
||||
stderr += c.toString('utf8');
|
||||
});
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolvePromise, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`process did not exit within 5s — looks like a hang. stderr so far: ${stderr}`)),
|
||||
5000
|
||||
);
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timeout);
|
||||
resolvePromise(code);
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
assert.notEqual(exitCode, 0, 'must exit non-zero when it cannot start at all');
|
||||
assert.match(stderr, /No key material configured/, 'the real, specific reason must reach stderr');
|
||||
assert.equal(stdout, '', 'nothing MCP-protocol-shaped should ever reach stdout on a startup failure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('key material is never emitted', () => {
|
||||
test('the startup banner (stderr) never contains a mnemonic word or the derived private key', async () => {
|
||||
const server = await spawnSigner();
|
||||
try {
|
||||
const banner = server.stderrChunks.join('');
|
||||
assert.ok(banner.length > 0, 'sanity: something was actually captured on stderr');
|
||||
assert.match(banner, /ready/i);
|
||||
for (const word of TEST_MNEMONIC.split(' ')) {
|
||||
assert.ok(!banner.includes(word), `startup banner must not contain mnemonic word "${word}": ${banner}`);
|
||||
}
|
||||
assert.ok(
|
||||
!banner.includes(TEST_IDENTITY.privateKeyBech32),
|
||||
'startup banner must not contain the derived private key'
|
||||
);
|
||||
} finally {
|
||||
await stopSigner(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('no tool response across a sweep of calls (including a real successful sign) contains a mnemonic word or the derived private key', async () => {
|
||||
const server = await spawnSigner();
|
||||
try {
|
||||
const inPolicyTx = buildInPolicyTxCbor(TEST_IDENTITY.address);
|
||||
|
||||
const responses: string[] = [];
|
||||
responses.push(await callToolText(server.client, 'vector_signer_get_address'));
|
||||
responses.push(await callToolText(server.client, 'vector_signer_decode_transaction', { txCbor: FIXTURE_CBOR }));
|
||||
responses.push(
|
||||
await callToolText(server.client, 'vector_signer_decode_transaction', { txCbor: inPolicyTx })
|
||||
);
|
||||
responses.push(await callToolText(server.client, 'vector_signer_sign', { txCbor: FIXTURE_CBOR })); // refused
|
||||
responses.push(await callToolText(server.client, 'vector_signer_sign', { txCbor: 'not-hex' })); // malformed
|
||||
responses.push(await callToolText(server.client, 'vector_signer_sign', { txCbor: inPolicyTx })); // actually signed
|
||||
responses.push(await callToolText(server.client, 'vector_signer_get_spend_limits'));
|
||||
|
||||
// Sanity: the sweep must actually exercise the real success path, or
|
||||
// this test would vacuously pass by never looking at a response that
|
||||
// could plausibly contain key material in the first place.
|
||||
assert.ok(responses.some((r) => /Signed CBOR/i.test(r)), 'sanity: the sweep must include a real signed response');
|
||||
|
||||
for (const [i, resp] of responses.entries()) {
|
||||
for (const word of TEST_MNEMONIC.split(' ')) {
|
||||
assert.ok(!resp.includes(word), `response #${i} contains mnemonic word "${word}": ${resp.slice(0, 300)}`);
|
||||
}
|
||||
assert.ok(!resp.includes(TEST_IDENTITY.privateKeyBech32), `response #${i} contains the raw private key`);
|
||||
}
|
||||
} finally {
|
||||
await stopSigner(server);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('decode_transaction is strictly read-only', () => {
|
||||
test('never appears in get_spend_limits recent decisions and never changes committed-today, even for an in-policy transaction', async () => {
|
||||
const server = await spawnSigner();
|
||||
try {
|
||||
const before = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||
assert.match(before, /No decisions recorded yet\./, 'sanity: starts with an empty audit log');
|
||||
|
||||
await callToolText(server.client, 'vector_signer_decode_transaction', { txCbor: FIXTURE_CBOR });
|
||||
await callToolText(server.client, 'vector_signer_decode_transaction', { txCbor: FIXTURE_CBOR });
|
||||
await callToolText(server.client, 'vector_signer_decode_transaction', {
|
||||
txCbor: buildInPolicyTxCbor(TEST_IDENTITY.address),
|
||||
});
|
||||
|
||||
const after = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||
assert.match(
|
||||
after,
|
||||
/No decisions recorded yet\./,
|
||||
'decode_transaction must never write an audit entry, even when previewing a transaction that would be ALLOWED'
|
||||
);
|
||||
assert.match(after, /Committed today:\s*0\.000000 AP3X/);
|
||||
} finally {
|
||||
await stopSigner(server);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a decode failure inside sign() is refused before any audit entry is even attempted', () => {
|
||||
test('malformed CBOR does not appear in recent decisions and does not change committed-today', async () => {
|
||||
const server = await spawnSigner();
|
||||
try {
|
||||
await callToolText(server.client, 'vector_signer_sign', { txCbor: 'not-hex' });
|
||||
const after = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||
assert.match(
|
||||
after,
|
||||
/No decisions recorded yet\./,
|
||||
'undecodable input never reaches evaluate(), so there is no PolicyDecision to audit yet — by design, not a gap'
|
||||
);
|
||||
assert.match(after, /Committed today:\s*0\.000000 AP3X/);
|
||||
} finally {
|
||||
await stopSigner(server);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_spend_limits surfaces the concrete refusal reason, not just the word REFUSED', () => {
|
||||
test('the per-transaction-limit reason text appears in recent decisions', async () => {
|
||||
const server = await spawnSigner();
|
||||
try {
|
||||
await callToolText(server.client, 'vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||
const limits = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||
assert.match(limits, /REFUSED/);
|
||||
assert.match(
|
||||
limits,
|
||||
/per-transaction limit/i,
|
||||
'the recent-decisions line should carry the actual policy reason, not just the decision label'
|
||||
);
|
||||
assert.match(limits, /Committed today:\s*0\.000000 AP3X/);
|
||||
} finally {
|
||||
await stopSigner(server);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('THE hard requirement: an audit append failure on the SIGNED path withholds the signature', () => {
|
||||
// audit.ts's own CONTRACT note (top of the file) is explicit: append()
|
||||
// throws if the durable write fails, and a caller that catches the throw
|
||||
// and still returns the signature it already produced reproduces exactly
|
||||
// the failure the throw exists to prevent — a real signature reaching a
|
||||
// caller with no durable record of it, so a restarted process's
|
||||
// committedTodayLovelace() silently under-counts and the daily limit
|
||||
// stops binding. This describe block is the test that proves tools.ts
|
||||
// does not fall into that trap.
|
||||
|
||||
test('control: the SAME in-policy transaction against a HEALTHY audit log genuinely signs (proves the fixture below is not simply broken)', async () => {
|
||||
const server = await spawnSigner();
|
||||
try {
|
||||
const tx = buildInPolicyTxCbor(TEST_IDENTITY.address);
|
||||
const result = await callToolText(server.client, 'vector_signer_sign', { txCbor: tx });
|
||||
assert.match(result, /^# Signed/);
|
||||
assert.match(result, /Signed CBOR/i);
|
||||
} finally {
|
||||
await stopSigner(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unwritable audit log withholds a genuinely-produced signature: no signed CBOR anywhere in the response', async () => {
|
||||
const server = await spawnSigner({
|
||||
// Mirrors audit.test.ts's own proven approach: a parent directory that
|
||||
// does not exist makes writeFileSync fail with ENOENT, deterministically
|
||||
// and cross-platform, without needing OS-specific permission tricks.
|
||||
auditLogPath: (dir) => join(dir, 'no-such-subdirectory', 'audit.json'),
|
||||
});
|
||||
try {
|
||||
const tx = buildInPolicyTxCbor(TEST_IDENTITY.address);
|
||||
const result = await callToolText(server.client, 'vector_signer_sign', { txCbor: tx });
|
||||
|
||||
assert.ok(!/Signed CBOR/i.test(result), 'a signature must never be returned when its audit record failed to write');
|
||||
assert.ok(!/```/.test(result), 'no fenced code block — where signed CBOR would appear — should be present at all');
|
||||
assert.match(result, /REFUSED/, 'the caller must get an unambiguous, non-signed response');
|
||||
assert.match(result, /produced/i, 'the response should be honest that a real signature WAS produced');
|
||||
assert.match(
|
||||
result,
|
||||
/(withheld|not been released)/i,
|
||||
'the response should say the signature is being withheld, not silently dropped'
|
||||
);
|
||||
} finally {
|
||||
await stopSigner(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('after a withheld signature, this process still counts it toward committed-today (conservative over-count, never an under-count)', async () => {
|
||||
// AuditLog.append() pushes the entry into its own in-memory array BEFORE
|
||||
// attempting the write (audit.ts's own comment explains why: so THIS
|
||||
// process's accounting does not under-count even when the write fails).
|
||||
// tools.ts has no way to "unpush" that entry, and should not try to -
|
||||
// over-counting a withheld signature against this process's own
|
||||
// remaining daily budget is the safe direction, matching every other
|
||||
// fail-closed choice in this package. Pinned here as a deliberate,
|
||||
// understood consequence rather than an accidental surprise.
|
||||
const server = await spawnSigner({
|
||||
auditLogPath: (dir) => join(dir, 'no-such-subdirectory', 'audit.json'),
|
||||
});
|
||||
try {
|
||||
const tx = buildInPolicyTxCbor(TEST_IDENTITY.address, 200_000n, 5_000_000n);
|
||||
await callToolText(server.client, 'vector_signer_sign', { txCbor: tx });
|
||||
|
||||
const limits = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||
assert.match(
|
||||
limits,
|
||||
/Committed today:\s*0\.200000 AP3X/,
|
||||
'the fee from the withheld signature is still counted in this process memory, even though it was never released'
|
||||
);
|
||||
} finally {
|
||||
await stopSigner(server);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a lower-stakes decision: an audit append failure on the REFUSED path', () => {
|
||||
// Funds do not move on a refusal either way, so this is lower stakes than
|
||||
// the signed case above — but a refusal whose own audit record silently
|
||||
// failed to write must not look like a normal, cleanly-recorded refusal to
|
||||
// the caller. Decision made here: still refuse (obviously — nothing about
|
||||
// an audit failure should ever cause a REFUSED decision to become
|
||||
// ALLOWED), still deliver the concrete policy reason, and additionally
|
||||
// disclose that the audit trail for this specific refusal is incomplete.
|
||||
|
||||
test('still refuses with the concrete reason, and additionally flags that the refusal itself could not be recorded', async () => {
|
||||
const server = await spawnSigner({
|
||||
auditLogPath: (dir) => join(dir, 'no-such-subdirectory', 'audit.json'),
|
||||
});
|
||||
try {
|
||||
// FIXTURE_CBOR is refused for TEST_MNEMONIC unconditionally (~1000 AP3X
|
||||
// net outflow against a 100 AP3X per-transaction limit — see the note
|
||||
// above the brief's own "sign refuses..." test). No funds are at risk
|
||||
// either way; this test is about whether the CALLER is told the truth
|
||||
// about the state of the audit trail.
|
||||
const result = await callToolText(server.client, 'vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||
|
||||
assert.match(result, /REFUSED/);
|
||||
assert.match(
|
||||
result,
|
||||
/per-transaction limit/i,
|
||||
'the concrete policy reason must still reach the caller even when the audit write fails'
|
||||
);
|
||||
assert.ok(!/Signed CBOR/i.test(result));
|
||||
assert.match(result, /audit log/i);
|
||||
assert.match(result, /(could not|NOT).*record/i, 'must disclose that this specific refusal was not durably recorded');
|
||||
} finally {
|
||||
await stopSigner(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('committed-today is unaffected by a refusal whether or not the audit write succeeds', async () => {
|
||||
const server = await spawnSigner({
|
||||
auditLogPath: (dir) => join(dir, 'no-such-subdirectory', 'audit.json'),
|
||||
});
|
||||
try {
|
||||
await callToolText(server.client, 'vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||
const limits = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||
assert.match(limits, /Committed today:\s*0\.000000 AP3X/);
|
||||
} finally {
|
||||
await stopSigner(server);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user