From 5c858f1143bf7e0099f91c8544c246fc1a0fa830 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:27:40 +0200 Subject: [PATCH 01/11] test: split unit and integration suites so CI can run without a wallet --- package.json | 4 +++- test/{ => integration}/run.test.ts | 2 +- test/{ => unit}/derive-asset-name.test.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) rename test/{ => integration}/run.test.ts (99%) rename test/{ => unit}/derive-asset-name.test.ts (95%) diff --git a/package.json b/package.json index fcaa206..5d47798 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "build": "tsup && chmod +x build/index.js", "typecheck": "tsc --noEmit --incremental", "start": "node build/index.js", - "test": "npm run build && node --import tsx --test test/run.test.ts" + "test": "npm run test:unit", + "test:unit": "node --import tsx --test test/unit/*.test.ts", + "test:integration": "npm run build && node --import tsx --test test/integration/*.test.ts" }, "files": [ "build" diff --git a/test/run.test.ts b/test/integration/run.test.ts similarity index 99% rename from test/run.test.ts rename to test/integration/run.test.ts index 1fde97e..f774494 100644 --- a/test/run.test.ts +++ b/test/integration/run.test.ts @@ -1,6 +1,6 @@ import { describe, test, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { startServer, stopServer, callTool, getMnemonic, wait, ServerContext } from './setup.ts'; +import { startServer, stopServer, callTool, getMnemonic, wait, ServerContext } from '../setup.ts'; // Always-succeeds PlutusV2 validator (accepts any datum/redeemer/context, returns True) const ALWAYS_SUCCEEDS_V2 = '49480100002221200101'; diff --git a/test/derive-asset-name.test.ts b/test/unit/derive-asset-name.test.ts similarity index 95% rename from test/derive-asset-name.test.ts rename to test/unit/derive-asset-name.test.ts index e5ec521..4eb0d62 100644 --- a/test/derive-asset-name.test.ts +++ b/test/unit/derive-asset-name.test.ts @@ -1,6 +1,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import { deriveNftAssetName } from '../src/vector/agent-network.ts'; +import { deriveNftAssetName } from '../../src/vector/agent-network.ts'; // Parity vectors — verified against pycardano OutputReference.to_cbor() and // agent-sdk-py plutus_serialise_data(0, [tx_hash_bytes, idx]). All three From a3c29306550551a9f8a7fabeb64e5fbf869e042c Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:35:54 +0200 Subject: [PATCH 02/11] chore: gitignore mnemonic.txt, .secrets/, and the audit log --- .gitignore | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 14e72d2..c49dda9 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,15 @@ coverage/ # Misc .tmp/ -.temp/ \ No newline at end of file +.temp/ + +# Secrets — never commit +mnemonic.txt +.secrets/ +*.mnemonic + +# Runtime state (contains transaction history) +vector-audit-log.json + +# Incremental typecheck cache (npm run typecheck uses --incremental) +*.tsbuildinfo \ No newline at end of file From 999a8bcc7f4ada0652f345364f199e1907c0f201 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:42:59 +0200 Subject: [PATCH 03/11] ci: run build and unit tests on PRs and main --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6ef81fa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + env: + NODE_OPTIONS: --max-old-space-size=12288 + + - name: Unit tests + run: npm run test:unit + env: + NODE_OPTIONS: --max-old-space-size=12288 From a3c38e70dbaadce18d7c5fbc389b98cb37d5b69d Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:52:31 +0200 Subject: [PATCH 04/11] docs: add non-custodial split architecture and correct the custody claim PR #2 shipped 'never stored server-side', which is true but materially incomplete - the mnemonic is a tool-call parameter, so it transits the MCP client, the model provider, and hosted process memory. Replaced with an accurate notice pointing at the architecture doc. --- README.md | 12 +- docs/architecture/non-custodial-split.md | 271 +++++++++++++++++++++++ 2 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/non-custodial-split.md diff --git a/README.md b/README.md index e74a362..a173979 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,14 @@ Connect from Claude Code in one command: claude mcp add --transport sse vector-mcp https://mcp.vector.mainnet.apexfusion.org/sse ``` -The mnemonic is passed per-call by the MCP client and is never stored server-side - see the [security model](https://apex-fusion.github.io/vector-ai-documentation/mcp-server/security/). Self-hosting instructions are below. +> **Security notice — custody.** Signing tools currently take your wallet mnemonic as a +> tool-call parameter. It therefore passes through your MCP client, your model provider, +> and the hosted server's memory — it is not written to disk, but it is exposed. Use only +> a hot wallet holding funds you can afford to lose, and prefer self-hosting until the +> non-custodial migration lands. See +> [docs/architecture/non-custodial-split.md](docs/architecture/non-custodial-split.md). + +Self-hosting instructions are below. ## Features @@ -102,7 +109,8 @@ cp .env.example .env # Edit .env with your endpoint URLs (defaults point to Vector testnet; mainnet URLs below) ``` -The mnemonic is passed per-call by the MCP client, not stored in the environment. +The mnemonic is passed per-call by the MCP client rather than stored in the environment. This +avoids environment dumps, but does not make it private — see the security notice above. ### 3. Run diff --git a/docs/architecture/non-custodial-split.md b/docs/architecture/non-custodial-split.md new file mode 100644 index 0000000..842d4ea --- /dev/null +++ b/docs/architecture/non-custodial-split.md @@ -0,0 +1,271 @@ +# Non-Custodial Split Architecture + +**Status:** approved · **Date:** 2026-07-27 + +Why the Vector MCP server is moving from seed-phrase-as-a-parameter to a keyless +hosted builder plus a local signer, and the PR sequence that gets us there. + +Companion security audit: internal (`mcp-server-security-analysis-v1_2026-07-24`). + +--- + +## 1. Problem + +Every signing tool on the Vector MCP server takes the user's BIP39 **seed phrase as a call parameter**. Because the LLM must emit that parameter to invoke the tool, the seed travels through the MCP client, **the model provider's context window and request logs**, the network, and finally the **process memory of a shared, unauthenticated hosted server**. A seed is root custody: unlimited, irrevocable, unscopable. + +The server's spend limits and rate limits are app-layer only, so they do not constrain anyone who has captured the seed. They are also **process-global singletons**, so on the hosted box every user shares one daily budget, one rate limit, and one audit file. + +Full detail: audit findings #1–#5. + +## 2. Goal + +**The agent holds its own key. The shared server never sees a seed.** + +Success criteria: +1. No hosted tool accepts a mnemonic or private key. Grep-able: zero `mnemonic` params in the builder package. +2. The signing key never enters the model context, never crosses the network. +3. Spend policy is enforced where it is actually enforceable — locally, per user. +4. Every PR in the series is independently reviewable and leaves `main` coherent. + +## 3. Decisions (locked) + +| Decision | Choice | +|---|---| +| Deployment reality | Hosted multi-tenant is flagship → seed-to-server is P0 | +| Architecture | Keyless hosted **builder** + local companion **signer** | +| Repo topology | Single repo, **npm workspaces** monorepo | +| Migration | **Hard cut per family** — no dual-mode, no deprecation window | +| Key storage | Pluggable `KeySource`; v1 = env var / `0600` file | +| Signer policy | Decode tx → **net outflow** → limits → audit → sign. Fully autonomous | +| Tool flow | 4 calls: `get_address` → `build_*` → `sign` → `submit` | +| Tool naming | Explicit `build_*` / `signer_*` / `submit_*` prefixes | +| Cross-cutting fixes | Entangled ones inline; independent ones as separate small PRs | +| Rollout | Testnet auto-deploys and may break; mainnet held until complete | +| Testing | **TDD** — tests written first | +| Auth | Enforced when `MCP_AUTH_TOKENS` is configured | +| Delivery | Claude implements and opens PRs; David/Časlav review and merge | + +## 4. Architecture + +``` +mcp-server/ npm workspaces root +├── packages/ +│ ├── shared/ @apexfusion/vector-mcp-shared +│ │ exports "./tx" pure CBOR, types, formatting — NO network +│ │ exports "./provider" OgmiosProvider, endpoints — network +│ ├── builder/ @apexfusion/vector-mcp-builder HOSTED · keyless · network +│ └── signer/ @apexfusion/vector-mcp-signer LOCAL · holds key · no network +``` + +Two properties carry the security argument: + +**The signer has no network interface, inbound or outbound.** It speaks **stdio** (the natural MCP local transport) — no port, no listener. Submission lives on the builder, so it has no egress either. The signer is a pure function: `CBOR in → signed CBOR out`, gated by policy. + +**The boundary is enforced by the module graph, not convention.** `shared` uses subpath exports; the signer imports `@apexfusion/vector-mcp-shared/tx` and *cannot* reach `/provider`. A reviewer can verify the signer is network-free by reading its imports. + +### Why zero-network is possible + +Net outflow is computable from the transaction body alone: + +``` +netOutflowLovelace = Σ { output.lovelace | output.address ∉ ownAddresses } + fee +``` + +No input resolution, therefore no chain access. Change returning to us is excluded automatically because its address is ours. + +**Known over-count:** an output to the user's own wallet at a *different derivation index* is classified as foreign and counted as outflow. This fails safe (refuses too much, never too little) and is accepted for v1. + +## 5. Components + +| Component | Package | Responsibility | +|---|---|---| +| `OgmiosProvider` | shared `/provider` | Chain queries + submit. Relocated unchanged. | +| CBOR / tx helpers | shared `/tx` | Decode, asset-name derivation, formatting. Pure. | +| Tool modules | builder | `build_*` per family, read-only queries, `submit`, `await` | +| Auth + rate limit | builder | Bearer token; **per-identity** limits replace the global singleton | +| `KeySource` | signer | `getSigningKey()`. v1: `EnvKeySource`, `FileKeySource` (0600) | +| `PolicyEngine` | signer | `evaluate(decoded, ownAddresses) → { allowed, reason, netOutflow }` | +| `SpendLimitPolicy` | signer | Ported `safety.ts`, now per-user and enforceable | +| Audit log | signer | Local, per-user | + +`PolicyEngine` is an interface with one v1 implementation so recipient-allowlist and threshold-approval land later without touching the sign path. + +### Interfaces + +```ts +interface KeySource { + getSigningKey(): Promise; + getAddress(): Promise; +} + +interface PolicyDecision { + allowed: boolean; + reason?: string; + netOutflowLovelace: bigint; + assetMovements: AssetMovement[]; +} + +interface Policy { + evaluate(tx: DecodedTx, ownAddresses: string[]): PolicyDecision; +} +``` + +## 6. Data flow + +``` +1. vector_signer_get_address → { address } [signer] +2. vector_build_send_apex → { txCbor, summary, fee } [builder, keyless] + { changeAddress, recipientAddress, amount } +3. vector_signer_sign → { signedTxCbor } [signer, key] + { txCbor } decode → net outflow → policy → audit → sign +4. vector_submit_transaction → { txHash, explorerUrl } [builder] + { signedTxCbor } +``` + +`vector_signer_decode_transaction` is a read-only preview so an agent can show a human what it is about to sign. + +**Accepted trade-off of the 4-call shape:** the signer records **intent to spend** at signing time, not confirmed submission. If a submit later fails, the audit log over-counts. Conservative, therefore acceptable — and it is the price of the signer having no network access. Documented, not hidden. + +### Two-step governance flow + +`submit_proposal` is a lock-then-spend pair with a confirmation wait between. Keyless, that orchestration becomes explicit: + +``` +build_self_improvement_proposal_lock → sign → submit → await_transaction + → build_self_improvement_proposal_spend(lockTxHash) → sign → submit +``` + +This is why governance is the last family: the LLM must drive a stateful, multi-transaction sequence. + +## 7. Tool inventory + +**Builder — 24 tools, zero keys.** + +| Group | Tools | +|---|---| +| Read-only (8, unchanged) | `get_balance`, `get_utxos`, `get_transaction_history`, `dry_run`, `discover_agents`, `get_agent_profile`, `self_improvement_browse`, `self_improvement_analyze_metrics` | +| Build — tx (3) | `build_send_apex`, `build_send_tokens`, `build_transaction` | +| Build — contracts (2) | `build_deploy_contract`, `build_interact_contract` | +| Build — registry (5) | `build_register_agent`, `build_update_agent`, `build_transfer_agent`, `build_deregister_agent`, `build_message_agent` | +| Build — governance (4) | `build_self_improvement_proposal_lock`, `build_self_improvement_proposal_spend`, `build_self_improvement_critique`, `build_self_improvement_endorse` | +| Chain ops (2, new) | `submit_transaction`, `await_transaction` | + +**Signer — 4 tools, holds the key.** + +`signer_get_address` · `signer_decode_transaction` · `signer_sign` · `signer_get_spend_limits` + +**Removed:** `vector_get_address` (replaced by `signer_get_address`), `vector_get_spend_limits` (moved to signer). + +## 8. Error handling + +**The signer fails closed.** Any CBOR it cannot fully parse, any unrecognised output type, any address it cannot classify as ours-or-foreign → **refuse**. A signer that guesses is worthless. Refusals return a structured reason the agent relays verbatim: `"net outflow 250.00 AP3X exceeds per-transaction limit 100.00 AP3X"`. + +**The builder never fabricates.** `submitTx` currently returns the first 64 hex chars of the CBOR as a fake tx hash on parse failure (audit #11) — it will return a real error. Error text stops echoing internal endpoint URLs (audit #13). + +## 9. Testing + +TDD throughout: failing test before implementation, every PR. + +| Layer | Coverage | Wallet | Network | +|---|---|---|---| +| `shared` | Golden CBOR encode/decode, asset-name derivation | no | no | +| `builder` | Fixture UTxOs + mocked provider → assert structure, fee, change | no | no | +| `signer` | **Adversarial** (below) | throwaway key | no | +| integration | build→sign→submit round trip | **funded testnet** | yes | + +**Mandatory adversarial cases for the signer** — these are the security boundary: + +- Drain to a foreign address exceeding the limit → **refused** +- Change returning to own address → **excluded** from net outflow +- Malformed / truncated CBOR → **refused**, never signed +- Unrecognised output type → **refused** +- Daily limit accumulation across multiple signs → enforced +- Fee included in net outflow + +Only the integration layer needs anything from Filip: **a funded Vector testnet wallet**. + +## 10. PR sequence + +### Spine (ordered) + +| PR | Content | Breaks testnet | +|---|---|---| +| **1** | CI harness: build + unit tests. Ships this design doc into `docs/architecture/` so reviewers have the rationale | no | +| **2** | MCP SDK upgrade + fix 8 type errors + restructure tool registration + **typecheck becomes a hard gate** | no | +| **3** | Workspace scaffolding — pure `git mv`, zero logic change, validated by PR 1–2 CI | no | +| **4** | **Signer package** (purely additive): `KeySource`, decode, `PolicyEngine`, audit, 4 tools. Heavy TDD | no | +| **5** | Builder hardening: auth (enforced when configured), per-identity rate limits, `submit_transaction`, `await_transaction` | no | +| **6** | **Family 1 — wallet/tx keyless.** Mnemonics removed; `build_*` tools land | **yes** | +| **7** | **Family 2 — agent registry keyless.** Drops `@ts-nocheck` from `agent-network.ts` | yes | +| **8** | **Family 3 — governance keyless.** Plus env-driven ref-UTxO config (#7) and CBOR encoding fix (#10) | yes | +| **9** | Mainnet cutover: README, security-page correction, deliberate `workflow_dispatch` | — | + +**PR 4 is the review that matters.** It is additive, self-contained, and *is* the security boundary. It breaks nothing while Časlav reads it. + +**PR 2 rationale:** `main` does not currently typecheck (§12). Gating CI on a red tree is impossible, so the gate is established before any refactor lands on top of it. + +### Independent singles (any order, parallel) + +| PR | Fix | Audit # | +|---|---|---| +| A | Koios v2 default; reconcile `.env.example` / `docker-compose` | #8 | +| B | `engines.node` floor; `postinstall` symlink hack | #15, #16 | +| C | `submitTx` fake tx hash | #11 | +| D | `min(1)` APEX floor → real dust floor | #12 | +| E | Endpoint URLs in error text | #13 | +| F | SSE → Streamable HTTP | #9 | +| G | Float → bigint money math | #14 | +| H | Dependency vulnerabilities (1 critical, 35 high) | new, §12 | + +Plus: **hold the "never stored server-side" reassurance line** on the existing open PR #2 — do not publish a safety claim about a design being removed. + +### Dependency graph + +``` +PR1 ─→ PR2 ─→ PR3 ─┬─→ PR4 (signer) ─┐ + └─→ PR5 (builder hardening) ─┴─→ PR6 ─→ PR7 ─→ PR8 ─→ PR9 + +A…H independent of the spine and of each other — land any time +``` + +PR 4 and PR 5 both depend on the workspace split (PR 3) and can proceed in parallel; PR 6 needs both, because the first keyless family requires a signer to sign with and a `submit_transaction` to submit through. + +## 11. Rollout + +Testnet auto-deploys on push to `main` (`deploy.yml`) and **is the proving ground** — breaking it from PR 6 onward is expected and useful signal. Mainnet deploys only by manual `workflow_dispatch` and **stays on the last custodial image until PR 9**. This uses the existing CI split as designed; no new infrastructure. + +## 12. Findings from local verification (new — fold into audit v2) + +Verified locally on Windows 11, **Node v25.8.0 / npm 11.11.0**: `npm ci` clean in 16 s (396 packages), `tsup` build succeeds (131 KB, 67 ms). + +> **Verification caveat:** local Node is **25.8.0**; the Dockerfile deploys on **node:22-alpine**, and `package.json` claims `>=14` (audit #15). Local green does not prove green on the deploy runtime. PR 1's CI must pin the **Node 22** matrix so the gate matches production, and PR B corrects the declared floor. + +**A. `npm run typecheck` fails on `main` — 8 errors, all `src/vector/vector.ts`.** + +``` +512,3 · 702,3 · 782,3 · 920,3 · 990,3 · 1192,3 · 1250,3 TS2589 instantiation excessively deep +1250,10 TS2769 no overload — z.record(z.string()).optional() +``` + +CI only runs `npm run build`, and `tsup`/esbuild strips types without checking them, so this has never been caught. + +**This corrects audit finding #6.** The `@ts-nocheck` headers are attributed in-code to "Cardano WASM imports," but the real cause is `server.tool()` generic inference exceeding TypeScript's instantiation depth on rich Zod schemas — the same failure leaking out of the one file lacking the escape hatch. Removing `@ts-nocheck` therefore requires restructuring tool registration, not a cleanup pass. Suspected root cause is the stale `@modelcontextprotocol/sdk@^1.4.1`, which would also explain the deprecated SSE transport (#9) — **unverified; PR 2 opens with a timeboxed spike.** + +**B. 52 dependency vulnerabilities (1 critical, 35 high).** Critical is `tar` (hardlink path traversal). Mostly tooling/transitive rather than runtime path, but warrants PR H and is poor optics in diligence. + +## 13. Residual risks + +**Native-asset outflow is not limited in v1.** Spend limits apply to lovelace. A transaction moving ~2 AP3X plus a valuable NFT to a foreign address passes the lovelace check. Blanket-denying asset outflow is not viable because legitimate registry flows mint an NFT to the registry script address. + +v1 mitigation: all asset movements are decoded, logged, and returned in the policy decision, and `assetOutflowPolicy` is configurable (`allow` default | `deny` | `allowlist`) for high-value wallets. **Stated as a known limitation, not glossed.** Post-v1: per-policy asset rules. + +**Key at rest is plaintext in v1** (env var or `0600` file). The win is that the key is *local and never transits the LLM or a shared host* — not that it is in an HSM. `KeySource` exists so keychain/HSM/hardware land later without touching the sign path. + +**Prompt injection is mitigated, not eliminated.** The signer refuses transactions violating policy, but an injected LLM can still cause any transaction *within* policy. Recipient allowlisting is the next lever. + +**SDK upgrade may not resolve TS2589.** PR 2's scope is contingent on the spike; if the upgrade does not fix it, fall back to extracting schemas into typed constants and re-scope PR 2. + +## 14. Open items + +1. **Funded Vector testnet wallet** needed from Filip for integration-layer verification. Without it, pre-PR testing proves CBOR structure but not chain acceptance. +2. **PR 2 spike result** determines whether the SDK upgrade is in scope or the type errors are patched directly. From 9ae2095c3a2691ef470ca54a9f87a8e4cc142dec Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:09:56 +0200 Subject: [PATCH 05/11] docs: redact unpatched vulnerability detail from the public architecture doc The repo is public and the referenced fixes are still several PRs out. Removes the internal audit pointer, the current auth-state confirmation, the submission failure-mode detail, and the dependency CVE specifics. Architecture, rationale, and PR sequence are unchanged. --- docs/architecture/non-custodial-split.md | 40 ++++++++++-------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/docs/architecture/non-custodial-split.md b/docs/architecture/non-custodial-split.md index 842d4ea..322eda7 100644 --- a/docs/architecture/non-custodial-split.md +++ b/docs/architecture/non-custodial-split.md @@ -5,18 +5,14 @@ Why the Vector MCP server is moving from seed-phrase-as-a-parameter to a keyless hosted builder plus a local signer, and the PR sequence that gets us there. -Companion security audit: internal (`mcp-server-security-analysis-v1_2026-07-24`). - --- ## 1. Problem -Every signing tool on the Vector MCP server takes the user's BIP39 **seed phrase as a call parameter**. Because the LLM must emit that parameter to invoke the tool, the seed travels through the MCP client, **the model provider's context window and request logs**, the network, and finally the **process memory of a shared, unauthenticated hosted server**. A seed is root custody: unlimited, irrevocable, unscopable. +Every signing tool on the Vector MCP server takes the user's BIP39 **seed phrase as a call parameter**. Because the LLM must emit that parameter to invoke the tool, the seed travels through the MCP client, **the model provider's context window and request logs**, the network, and finally the **process memory of the hosted server**. A seed is root custody: unlimited, irrevocable, unscopable. The server's spend limits and rate limits are app-layer only, so they do not constrain anyone who has captured the seed. They are also **process-global singletons**, so on the hosted box every user shares one daily budget, one rate limit, and one audit file. -Full detail: audit findings #1–#5. - ## 2. Goal **The agent holds its own key. The shared server never sees a seed.** @@ -160,7 +156,7 @@ This is why governance is the last family: the LLM must drive a stateful, multi- **The signer fails closed.** Any CBOR it cannot fully parse, any unrecognised output type, any address it cannot classify as ours-or-foreign → **refuse**. A signer that guesses is worthless. Refusals return a structured reason the agent relays verbatim: `"net outflow 250.00 AP3X exceeds per-transaction limit 100.00 AP3X"`. -**The builder never fabricates.** `submitTx` currently returns the first 64 hex chars of the CBOR as a fake tx hash on parse failure (audit #11) — it will return a real error. Error text stops echoing internal endpoint URLs (audit #13). +**The builder never fabricates.** Submission returns a real error when it fails, never a synthesised or placeholder result. ## 9. Testing @@ -197,7 +193,7 @@ Only the integration layer needs anything from Filip: **a funded Vector testnet | **5** | Builder hardening: auth (enforced when configured), per-identity rate limits, `submit_transaction`, `await_transaction` | no | | **6** | **Family 1 — wallet/tx keyless.** Mnemonics removed; `build_*` tools land | **yes** | | **7** | **Family 2 — agent registry keyless.** Drops `@ts-nocheck` from `agent-network.ts` | yes | -| **8** | **Family 3 — governance keyless.** Plus env-driven ref-UTxO config (#7) and CBOR encoding fix (#10) | yes | +| **8** | **Family 3 — governance keyless.** Plus env-driven ref-UTxO config and a CBOR encoding fix | yes | | **9** | Mainnet cutover: README, security-page correction, deliberate `workflow_dispatch` | — | **PR 4 is the review that matters.** It is additive, self-contained, and *is* the security boundary. It breaks nothing while Časlav reads it. @@ -206,16 +202,16 @@ Only the integration layer needs anything from Filip: **a funded Vector testnet ### Independent singles (any order, parallel) -| PR | Fix | Audit # | -|---|---|---| -| A | Koios v2 default; reconcile `.env.example` / `docker-compose` | #8 | -| B | `engines.node` floor; `postinstall` symlink hack | #15, #16 | -| C | `submitTx` fake tx hash | #11 | -| D | `min(1)` APEX floor → real dust floor | #12 | -| E | Endpoint URLs in error text | #13 | -| F | SSE → Streamable HTTP | #9 | -| G | Float → bigint money math | #14 | -| H | Dependency vulnerabilities (1 critical, 35 high) | new, §12 | +| PR | Fix | +|---|---| +| A | Koios v2 default; reconcile `.env.example` / `docker-compose` | +| B | `engines.node` floor; `postinstall` symlink hack | +| C | Submission error handling | +| D | `min(1)` APEX floor → real dust floor | +| E | Endpoint URLs in error text | +| F | SSE → Streamable HTTP | +| G | Float → bigint money math | +| H | Dependency updates | Plus: **hold the "never stored server-side" reassurance line** on the existing open PR #2 — do not publish a safety claim about a design being removed. @@ -234,13 +230,13 @@ PR 4 and PR 5 both depend on the workspace split (PR 3) and can proceed in paral Testnet auto-deploys on push to `main` (`deploy.yml`) and **is the proving ground** — breaking it from PR 6 onward is expected and useful signal. Mainnet deploys only by manual `workflow_dispatch` and **stays on the last custodial image until PR 9**. This uses the existing CI split as designed; no new infrastructure. -## 12. Findings from local verification (new — fold into audit v2) +## 12. Findings from local verification Verified locally on Windows 11, **Node v25.8.0 / npm 11.11.0**: `npm ci` clean in 16 s (396 packages), `tsup` build succeeds (131 KB, 67 ms). -> **Verification caveat:** local Node is **25.8.0**; the Dockerfile deploys on **node:22-alpine**, and `package.json` claims `>=14` (audit #15). Local green does not prove green on the deploy runtime. PR 1's CI must pin the **Node 22** matrix so the gate matches production, and PR B corrects the declared floor. +> **Verification caveat:** local Node is **25.8.0**; the Dockerfile deploys on **node:22-alpine**, and `package.json` claims `>=14`. Local green does not prove green on the deploy runtime. PR 1's CI must pin the **Node 22** matrix so the gate matches production, and PR B corrects the declared floor. -**A. `npm run typecheck` fails on `main` — 8 errors, all `src/vector/vector.ts`.** +**`npm run typecheck` fails on `main` — 8 errors, all `src/vector/vector.ts`.** ``` 512,3 · 702,3 · 782,3 · 920,3 · 990,3 · 1192,3 · 1250,3 TS2589 instantiation excessively deep @@ -249,9 +245,7 @@ Verified locally on Windows 11, **Node v25.8.0 / npm 11.11.0**: `npm ci` clean i CI only runs `npm run build`, and `tsup`/esbuild strips types without checking them, so this has never been caught. -**This corrects audit finding #6.** The `@ts-nocheck` headers are attributed in-code to "Cardano WASM imports," but the real cause is `server.tool()` generic inference exceeding TypeScript's instantiation depth on rich Zod schemas — the same failure leaking out of the one file lacking the escape hatch. Removing `@ts-nocheck` therefore requires restructuring tool registration, not a cleanup pass. Suspected root cause is the stale `@modelcontextprotocol/sdk@^1.4.1`, which would also explain the deprecated SSE transport (#9) — **unverified; PR 2 opens with a timeboxed spike.** - -**B. 52 dependency vulnerabilities (1 critical, 35 high).** Critical is `tar` (hardlink path traversal). Mostly tooling/transitive rather than runtime path, but warrants PR H and is poor optics in diligence. +**This corrects an earlier misattribution.** The `@ts-nocheck` headers are attributed in-code to "Cardano WASM imports," but the real cause is `server.tool()` generic inference exceeding TypeScript's instantiation depth on rich Zod schemas — the same failure leaking out of the one file lacking the escape hatch. Removing `@ts-nocheck` therefore requires restructuring tool registration, not a cleanup pass. Suspected root cause is the stale `@modelcontextprotocol/sdk@^1.4.1`, which would also explain the deprecated SSE transport — **unverified; PR 2 opens with a timeboxed spike.** ## 13. Residual risks From ca947224125bbbe222876e085fb78f8f199f3993 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:34:32 +0200 Subject: [PATCH 06/11] docs: correct README testing instructions for unit/integration split The Testing section still told contributors to write a real mnemonic to mnemonic.txt and run `npm test`, then claimed the result was full agent-lifecycle coverage against Vector testnet. Since test:unit is now the default `test` script (five wallet-free CBOR assertions), that instruction produced a false green and silently skipped the coverage it advertised. Document test:unit and test:integration separately and drop the stale VECTOR_MNEMONIC env-var alternative, which setup.ts never reads. --- README.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a173979..96b0639 100644 --- a/README.md +++ b/README.md @@ -178,14 +178,17 @@ docker run -p 3000:3000 vector-mcp ## Testing ```bash -# Set the wallet mnemonic (file or env var) -echo "your mnemonic words here" > mnemonic.txt -# or: export VECTOR_MNEMONIC="your mnemonic words here" - -npm test +npm run test:unit ``` -Tests cover the core tools end-to-end against Vector testnet, including the full agent lifecycle: register, discover, profile, update, transfer, message, and deregister. +No wallet, no network — pure logic only (CBOR encode/decode assertions). This is what CI runs on every PR. + +```bash +echo "your mnemonic words here" > mnemonic.txt +npm run test:integration +``` + +Requires `mnemonic.txt` in the repo root containing a **funded Vector testnet** mnemonic. Covers the core tools end-to-end against Vector testnet, including the full agent lifecycle: register, discover, profile, update, transfer, message, and deregister. Never runs in CI. ## Architecture From 9d4958500efaba13dc0e6b087d2faca99b28f635 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:34:43 +0200 Subject: [PATCH 07/11] test: add tool-inventory smoke test asserting the 23-tool surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every PR in the non-custodial-split series is supposed to preserve `tools/list` returning 23 tools with identical schemas, but nothing enforced it — PRs 2, 6, 7 and 8 will restructure and rename tool families, and a snapshot only means something if it's taken before that starts. Boot the built server, call listTools(), and assert both the count and the exact sorted name list against a checked-in baseline captured from a live run on this branch. Failures report which names were added/removed rather than a bare array-diff. Reuses startServer()/stopServer() from test/setup.ts; does not call getMnemonic(), so it needs no wallet. Wired into CI as a new step after the unit tests. Also raises the server-start timeout in setup.ts from 15s to 60s — the Cardano WASM import makes cold starts slow enough that 15s was flaky on the smoke test's first CI run. test:smoke is intentionally separate from test:unit (which does not build) since startServer() spawns build/index.js. --- .github/workflows/ci.yml | 5 +++ package.json | 3 +- test/setup.ts | 2 +- test/smoke/tool-inventory.test.ts | 75 +++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 test/smoke/tool-inventory.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ef81fa..ce7fcd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,8 @@ jobs: run: npm run test:unit env: NODE_OPTIONS: --max-old-space-size=12288 + + - name: Tool inventory smoke test + run: npm run test:smoke + env: + NODE_OPTIONS: --max-old-space-size=12288 diff --git a/package.json b/package.json index 5d47798..cfa4212 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "start": "node build/index.js", "test": "npm run test:unit", "test:unit": "node --import tsx --test test/unit/*.test.ts", - "test:integration": "npm run build && node --import tsx --test test/integration/*.test.ts" + "test:integration": "npm run build && node --import tsx --test test/integration/*.test.ts", + "test:smoke": "npm run build && node --import tsx --test test/smoke/*.test.ts" }, "files": [ "build" diff --git a/test/setup.ts b/test/setup.ts index 0651be1..37a7cac 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -31,7 +31,7 @@ export async function startServer(): Promise { }); await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Server start timeout (15s)')), 15_000); + const timeout = setTimeout(() => reject(new Error('Server start timeout (60s)')), 60_000); child.stderr!.on('data', (chunk: Buffer) => { if (chunk.toString().includes('listening on port')) { clearTimeout(timeout); diff --git a/test/smoke/tool-inventory.test.ts b/test/smoke/tool-inventory.test.ts new file mode 100644 index 0000000..fd855f8 --- /dev/null +++ b/test/smoke/tool-inventory.test.ts @@ -0,0 +1,75 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer, stopServer, ServerContext } from '../setup.ts'; + +/** + * Snapshot of the pre-migration tool surface (23 tools), captured from a real + * `listTools()` call against a built server on the `ci/harness-and-gitignore` + * branch. This is the baseline for the non-custodial split series + * (see docs/architecture/non-custodial-split.md) — PRs 2, 6, 7 and 8 + * deliberately restructure and rename tool families, and each of those PRs is + * expected to update this list deliberately, not accidentally. No wallet, + * no network beyond localhost — this never needs a mnemonic. + */ +const EXPECTED_TOOLS = [ + 'vector_build_transaction', + 'vector_deploy_contract', + 'vector_deregister_agent', + 'vector_discover_agents', + 'vector_dry_run', + 'vector_get_address', + 'vector_get_agent_profile', + 'vector_get_balance', + 'vector_get_spend_limits', + 'vector_get_transaction_history', + 'vector_get_utxos', + 'vector_interact_contract', + 'vector_message_agent', + 'vector_register_agent', + 'vector_self_improvement_analyze_metrics', + 'vector_self_improvement_browse', + 'vector_self_improvement_critique', + 'vector_self_improvement_endorse', + 'vector_self_improvement_submit_proposal', + 'vector_send_apex', + 'vector_send_tokens', + 'vector_transfer_agent', + 'vector_update_agent', +]; + +let ctx: ServerContext; +let actualNames: string[]; + +before(async () => { + ctx = await startServer(); + const result = await ctx.client.listTools(); + actualNames = result.tools.map((t) => t.name).sort(); +}); + +after(async () => { + await stopServer(ctx); +}); + +describe('tool inventory smoke test', () => { + test('exposes exactly 23 tools', () => { + assert.equal( + actualNames.length, + 23, + `expected 23 tools, got ${actualNames.length}: ${JSON.stringify(actualNames)}` + ); + }); + + test('tool names match the checked-in inventory exactly', () => { + const added = actualNames.filter((name) => !EXPECTED_TOOLS.includes(name)); + const removed = EXPECTED_TOOLS.filter((name) => !actualNames.includes(name)); + + assert.deepEqual( + actualNames, + EXPECTED_TOOLS, + 'Tool inventory drifted from the checked-in snapshot in test/smoke/tool-inventory.test.ts.\n' + + ` added: ${added.length ? JSON.stringify(added) : '(none)'}\n` + + ` removed: ${removed.length ? JSON.stringify(removed) : '(none)'}\n` + + 'If this drift is intentional, update EXPECTED_TOOLS in this file to match.' + ); + }); +}); From 01416af3ab8b5920072e5e3eacb17f2b80da7c84 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:35:01 +0200 Subject: [PATCH 08/11] ci: harden workflow permissions, add timeout and concurrency gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - permissions: contents: read — this workflow only builds and tests, it never writes to the repo; matches the convention deploy.yml already sets. - timeout-minutes: 15 on the job so a hung step doesn't burn runner time indefinitely. - concurrency group keyed on the ref so a new push cancels the in-flight run for the same branch/PR instead of queuing behind it. Action versions are left unpinned (still @v4) — SHA-pinning only this workflow while deploy.yml stays on @v4 would be an inconsistency to clean up in its own pass, not here. --- .github/workflows/ci.yml | 8 ++++++++ .gitignore | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce7fcd0..8d97306 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,17 @@ on: push: branches: [main] +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: build-and-test: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index c49dda9..8b089dc 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,4 @@ mnemonic.txt vector-audit-log.json # Incremental typecheck cache (npm run typecheck uses --incremental) -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo From 854c5b0005468ef6249a70dbe86eac137e8341fe Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:35:10 +0200 Subject: [PATCH 09/11] docs: remove internal planning detail from the public architecture doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This doc ships into a public repo (PR 1's own remit). Strip process notes that read as an internal artifact rather than architecture: the Decisions-table row naming who implements/reviews each PR, the two "needs a funded testnet wallet from Filip" notes (also invites "so it's untested?" from outside readers), and the instruction to hold a line on PR #2, which already merged. Renumbered the Open items list after dropping the wallet entry. The named-reviewer phrase in section 10 falls in the same category and is genericized for the same reason, though it wasn't separately called out. Section 13 (residual risks) and the §12 typecheck findings are untouched — those are load-bearing technical content, not planning chatter. --- docs/architecture/non-custodial-split.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/docs/architecture/non-custodial-split.md b/docs/architecture/non-custodial-split.md index 322eda7..052ef7e 100644 --- a/docs/architecture/non-custodial-split.md +++ b/docs/architecture/non-custodial-split.md @@ -39,7 +39,6 @@ Success criteria: | Rollout | Testnet auto-deploys and may break; mainnet held until complete | | Testing | **TDD** — tests written first | | Auth | Enforced when `MCP_AUTH_TOKENS` is configured | -| Delivery | Claude implements and opens PRs; David/Časlav review and merge | ## 4. Architecture @@ -178,8 +177,6 @@ TDD throughout: failing test before implementation, every PR. - Daily limit accumulation across multiple signs → enforced - Fee included in net outflow -Only the integration layer needs anything from Filip: **a funded Vector testnet wallet**. - ## 10. PR sequence ### Spine (ordered) @@ -196,7 +193,7 @@ Only the integration layer needs anything from Filip: **a funded Vector testnet | **8** | **Family 3 — governance keyless.** Plus env-driven ref-UTxO config and a CBOR encoding fix | yes | | **9** | Mainnet cutover: README, security-page correction, deliberate `workflow_dispatch` | — | -**PR 4 is the review that matters.** It is additive, self-contained, and *is* the security boundary. It breaks nothing while Časlav reads it. +**PR 4 is the review that matters.** It is additive, self-contained, and *is* the security boundary. It breaks nothing while it is reviewed. **PR 2 rationale:** `main` does not currently typecheck (§12). Gating CI on a red tree is impossible, so the gate is established before any refactor lands on top of it. @@ -213,8 +210,6 @@ Only the integration layer needs anything from Filip: **a funded Vector testnet | G | Float → bigint money math | | H | Dependency updates | -Plus: **hold the "never stored server-side" reassurance line** on the existing open PR #2 — do not publish a safety claim about a design being removed. - ### Dependency graph ``` @@ -261,5 +256,4 @@ v1 mitigation: all asset movements are decoded, logged, and returned in the poli ## 14. Open items -1. **Funded Vector testnet wallet** needed from Filip for integration-layer verification. Without it, pre-PR testing proves CBOR structure but not chain acceptance. -2. **PR 2 spike result** determines whether the SDK upgrade is in scope or the type errors are patched directly. +1. **PR 2 spike result** determines whether the SDK upgrade is in scope or the type errors are patched directly. From 790cbf80b8aa6334fcdda25a360b0f5afd426ad7 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:57:11 +0200 Subject: [PATCH 10/11] docs: document the test:smoke command in the Testing section CI runs test:smoke on every PR but the README only listed test:unit and test:integration, so a contributor following the README wouldn't know to run it locally. --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 96b0639..0e5b1cf 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,13 @@ docker run -p 3000:3000 vector-mcp npm run test:unit ``` -No wallet, no network — pure logic only (CBOR encode/decode assertions). This is what CI runs on every PR. +No wallet, no network — pure logic only (CBOR encode/decode assertions). + +```bash +npm run test:smoke +``` + +Builds the server, boots it, and asserts the exposed tool inventory matches the checked-in snapshot. No wallet, no external network. CI runs this and `test:unit` on every PR. ```bash echo "your mnemonic words here" > mnemonic.txt From 39e33628e4f7894e8327b107b35995585a1110ab Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:03:56 +0200 Subject: [PATCH 11/11] docs: correct the Koios default in the README config table The v2 Koios migration landed in main, so the config table's v1 default and the 503-workaround note are both now false. --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 0e5b1cf..e124011 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ docker run -p 3000:3000 vector-mcp |----------|-------------|---------| | `PORT` | HTTP server port | `3000` | | `VECTOR_OGMIOS_URL` | Ogmios HTTP JSON-RPC endpoint | `https://ogmios.vector.testnet.apexfusion.org` | -| `VECTOR_KOIOS_URL` | Koios REST API endpoint | `https://koios.vector.testnet.apexfusion.org/` | +| `VECTOR_KOIOS_URL` | Koios REST API endpoint | `https://v2.koios.vector.testnet.apexfusion.org/` | | `VECTOR_SUBMIT_URL` | Transaction submit API | `https://submit.vector.testnet.apexfusion.org/api/submit/tx` | | `VECTOR_EXPLORER_URL` | Block explorer base URL | `https://vector.testnet.apexscan.org` | | `VECTOR_SPEND_LIMIT_PER_TX` | Max lovelace per transaction | `100000000` (100 AP3X) | @@ -164,8 +164,6 @@ docker run -p 3000:3000 vector-mcp | `VECTOR_AUDIT_LOG_PATH` | Persistent audit log file path | `./vector-audit-log.json` | | `VECTOR_RATE_LIMIT_PER_MINUTE` | Max tool calls per minute | `60` | -> **Note:** the default testnet Koios URL predates the v2 migration and currently returns 503. Override `VECTOR_KOIOS_URL` with `https://v2.koios.vector.testnet.apexfusion.org/` until the default is updated in code. - ### Mainnet endpoints | Variable | Mainnet value |