Commit Graph

3 Commits

Author SHA1 Message Date
FilipBl4gojevic
d4a1a217a6 test(signer): pin decode's read-only contract and stdout purity directly
A decode_transaction that signs only when policy-allowed, worded to avoid
"sign", passed all 21 tests - a preview tool returning real signed bytes with
no audit entry and no budget consumed. And the stdout-purity constraint was
only enforced incidentally by an unrelated stderr sanity check, while the MCP
client silently swallows unparseable stdout lines.

Gap 1 (decode-can-sign) is closed structurally, not just by test: tools.ts
splits into registerReadOnlyTools() (get_address, decode_transaction,
get_spend_limits - receives only signerAddress/limits/auditLogPath/audit,
never config or keySource) and registerSignTool() (the only function in the
module whose closure ever holds a loaded SigningIdentity). Reproducing the
review's exact mutation against the new code is now a compile error
(TS2304: Cannot find name 'identity') rather than a passing test suite.
Defense-in-depth on top of that: a new content-based test independently
computes what signing the previewed transaction would actually produce and
asserts decode's response never contains those bytes, under any wording -
this is what catches a more determined mutation that deliberately re-widens
the read-only context to reach signing capability again.

Gap 2 (stdout purity) is closed with a purpose-built minimal Transport
(RawCapturingStdioTransport) that captures every raw stdout byte independently
of the SDK's own lenient line parsing, while still driving the real Client
class for a correct initialize handshake. Asserts every line written to
stdout, across a healthy startup and a normal tool call, parses as JSON.

Both fixes reproduced-then-verified: applying the review's mutations first
confirmed each gap was real (all tests green under the vulnerable code),
then confirmed each new check catches it, then confirmed restoring the fix
returns to 23/23 with no collateral. Also re-ran the original hard-requirement
mutations (signed- and refused-path unguarded audit.append, the exact
catch{warn();return signature} anti-pattern, and a missing return after the
refused branch that lets a refused transaction fall through to real signing)
against the restructured tools.ts to confirm the refactor weakened nothing -
none did; the missing-return mutation alone is caught by 4 tests, including
3 of the brief's own original 9.

typecheck 0, test:unit 180/180, builder test:smoke 26/23/0, boundary 4/4,
signer test:smoke:signer 23/23 (9 brief + 12 original edge-case pass + 2 new).
2026-07-28 17:28:25 +02:00
FilipBl4gojevic
ece9ec9ab8 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).
2026-07-28 16:36:58 +02:00
FilipBl4gojevic
b0faac8c12 feat(signer): MCP server with get_address, decode, sign, get_spend_limits
stdio transport only - no HTTP listener, no Provider, no outbound network.
Wires together decode.ts/policy.ts/sign.ts/audit.ts/keysource.ts (Tasks 2-6)
behind four tools: vector_signer_get_address, vector_signer_decode_transaction,
vector_signer_sign, vector_signer_get_spend_limits.

Two corrections needed beyond the brief's given code to reach its own stated
"pass 9, fail 0" outcome, both verified necessary by reproducing the failure
first:

- packages/signer/tsup.config.ts: target bumped es2020 -> es2022. index.ts's
  top-level `await server.connect(...)` is ES2022 syntax; esbuild hard-errors
  ("Top-level await is not available in the configured target environment")
  under the package's original es2020 target rather than attempting an unsafe
  downlevel transform. Scoped to the signer package only - the builder never
  uses top-level await and its own tsup.config.ts is untouched.
- tools.ts's decode_transaction response text: "(not signed)" and "would be
  signed" both contain the substring "signed", which collides with this same
  smoke test's own `!/signed/i.test(text)` check on every call, including the
  refused case. Reworded to "(read-only)" / "would be ALLOWED" - same meaning,
  no collision. Fixed the response text rather than loosening the test, which
  stays exactly as given.

Brief's own smoke test (9 tests) passes verbatim. Workspace-wide: typecheck 0,
test:unit 180/180 (no regressions), builder test:smoke still 26/23/0.

The audit-append-failure handling required by the task (a produced signature
must never be released if its audit record fails to write) is not yet in this
commit - see the follow-up commit.
2026-07-28 16:22:19 +02:00