diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..496cb03 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install + run: bun install --frozen-lockfile + - name: Typecheck + build + run: bun run build + - name: Test + run: bun test diff --git a/README.md b/README.md index 5e18162..0b776fd 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,25 @@
-# πŸ›‘οΈ plugin-tereno +Tereno -### Your agent's onchain instinct β€” verdicts before it signs, income while it sleeps +# plugin-tereno -[![npm version](https://img.shields.io/npm/v/plugin-tereno?style=flat-square&color=00ff66)](https://www.npmjs.com/package/plugin-tereno) -[![ElizaOS](https://img.shields.io/badge/ElizaOS-%E2%89%A51.0-blueviolet?style=flat-square)](https://elizaos.ai) -[![Base](https://img.shields.io/badge/Base-mainnet-0052FF?style=flat-square)](https://base.org) -[![x402](https://img.shields.io/badge/payments-x402%20%2F%20USDC-2775CA?style=flat-square)](https://www.x402.org) -[![Discord](https://img.shields.io/badge/community-Discord-5865F2?style=flat-square)](https://discord.gg/YsgGSRBxn) +### πŸ›‘οΈ A pre-signature security layer for ElizaOS agents on Base -**No API key. No signup. No subscription.** -Your agent's wallet *is* the account β€” it pays per answer in USDC over x402, -and earns its own safety budget by contributing verified work back to the network. +**Before your agent signs, it checks.** One paid call flags unlimited approvals, +suspicious contracts, unexpected balance changes and abnormal gas β€” and returns +a single, standardized verdict your agent can branch on. + +
+ +[![npm version](https://img.shields.io/npm/v/plugin-tereno?style=for-the-badge&color=0052FF&logo=npm)](https://www.npmjs.com/package/plugin-tereno) +[![ElizaOS](https://img.shields.io/badge/ElizaOS-%E2%89%A51.0-8B5CF6?style=for-the-badge)](https://elizaos.ai) +[![Base](https://img.shields.io/badge/Base-0052FF?style=for-the-badge&logo=coinbase&logoColor=white)](https://base.org) +[![x402](https://img.shields.io/badge/x402%20%C2%B7%20USDC-2775CA?style=for-the-badge)](https://www.x402.org) + +[![CI](https://img.shields.io/github/actions/workflow/status/RobMartello/plugin-tereno/ci.yml?style=flat-square&label=CI)](https://github.com/RobMartello/plugin-tereno/actions) +[![license](https://img.shields.io/npm/l/plugin-tereno?style=flat-square&color=green)](./LICENSE) +[![fail-closed](https://img.shields.io/badge/default-fail--closed-critical?style=flat-square)](#fail-open-vs-fail-closed) ```bash bun add plugin-tereno @@ -24,115 +31,193 @@ bun add plugin-tereno ## ⚑ Why this exists -Every autonomous agent on Base faces the same two problems: +Autonomous agents sign transactions with no instinct for danger. One malicious +approval, one drainer contract, one rug-after-audit, and the wallet is gone. + +`plugin-tereno` gives an ElizaOS agent a **pre-signature check**: before it signs +a Base transaction, it buys a bounded verdict from [Tereno](https://www.tereno.xyz) +that simulates the call and flags the ways it could lose funds. **Failed calls are +never charged** β€” x402 settles only after a successful response. + +- **No API key, no signup.** The agent's wallet *is* the identity; it pays a few + cents per check in USDC over x402. +- **Budget-capped.** Per-call and rolling-daily USDC limits β€” a chatty agent can't + drain its wallet. +- **Fail-closed by default.** If the check can't complete, a financial action is + told to **abort**, not to sign blind. + +## 🚦 The core check + +``` +TERENO_CHECK_TRANSACTION +{"from":"0x…","to":"0x…","data":"0x…","value":"0"} + β”‚ + β–Ό + decision Β· riskLevel Β· reason Β· exposure Β· cost Β· recommendedAction +``` + +Every guard action returns the **same standardized verdict**: -1. **It signs blind.** One malicious approval and the wallet is gone. -2. **It recomputes what thousands of other agents already computed.** Same - token lookups, same contract checks, same RPC work β€” paid N times. +```ts +interface TerenoVerdict { + decision: "allow" | "review" | "block"; + riskLevel: "none" | "low" | "medium" | "high"; + reason: string; // human-readable summary + reason codes + exposure: { // what the tx would change + balanceDeltas?: unknown; + allowanceChanges?: unknown; + unlimitedApproval: boolean; + }; + cost: { paidUsd: number; capUsd: number; dailySpentUsd: number }; + recommendedAction: "sign" | "review" | "abort"; + receiptUrl: string | null; + raw: unknown; // full Tereno payload, unchanged +} +``` -[Tereno](https://www.tereno.xyz) is a coordination layer that fixes both: -bounded pre-sign verdicts for cents, served from a **shared cache that gets -cheaper the more agents use it** β€” and pays the agent who seeded each answer. +Your agent branches on one field: `recommendedAction`. -## πŸ” Spend loop β€” answers before actions +## πŸ” What it checks | Action | Price | The question it answers | |---|---:|---| | 🚦 `TERENO_CHECK_TRANSACTION` | $0.005 | **Is this safe to sign?** Simulation, gas, balance/allowance deltas, drainer & unlimited-approval flags | | πŸ›‘οΈ `TERENO_CHECK_CONTRACT` | $0.001–3 | Is this contract safe to interact with? (code, proxy, pause) | -| πŸ•΅οΈ `TERENO_CONTRACT_CHANGED` | $0.001 | Did it change since last look? The rug-pull-after-audit detector | +| πŸ•΅οΈ `TERENO_CONTRACT_CHANGED` | $0.001 | Did it change since last look? (rug-pull-after-audit detector) | | πŸͺ™ `TERENO_TOKEN_INFO` | $0.001 | What token is this, really? (name, symbol, decimals, supply) | | 🧬 `TERENO_CONTRACT_INTERFACE` | $0.001 | ERC-20? NFT? Proxy? One bounded probe | | β›½ `TERENO_CHAIN_SNAPSHOT` | $0.001 | Latest Base block + gas, zero parameters | +| 🧾 `TERENO_PREVIEW` | free | What the checks cover, cost and current config | | πŸ“– `TERENO_CATALOG` | free | Live catalog and prices | -Every verdict ships with reason codes, evidence, block freshness and -**explicit limitations**. Failed calls are never charged β€” x402 settles only -after a successful response. - -## 🌱 Earn loop β€” the agent funds itself - -The network publishes its **unmet demand as open bounties**. Your agent can -claim them: - -| Action | Cost | What happens | -|---|---:|---| -| πŸ“‹ `TERENO_BOUNTIES` | free | See what the network will pay for next | -| 🌱 `TERENO_SEED` | free–$0.001 | Compute a contract fingerprint locally (free RPC work), publish it signed, pass the on-chain recomputation audit β†’ **claim the seeder slot** | -| πŸ“ˆ `TERENO_STANDING` | free | Audited track record: publications, reuses, credits | -| ✍️ `TERENO_REPORT_OUTCOME` | free | Honest signed feedback β†’ review credit | - -**Seeders earn 20% of every settled cross-wallet reuse** of their work, as -credits that discount the guard calls. Buy protection β†’ contribute work β†’ -protection pays for itself. That's the flywheel. +## πŸš€ Quickstart (free, no wallet needed) -> **The honest fine print** (we lead with it on purpose): rewards are -> non-transferable credits, capped per artifact. This funds your agent's -> safety β€” it is not yield farming. Reputation is computed by the network -> from recomputation audits and settled economics, never from claims. +Dry-run mode wires the whole flow with **no payment**, so you can integrate first +and spend later: -## πŸš€ Quick start +```bash +# .env +TERENO_DRY_RUN=true # no x402 payment; validates + shapes the verdict +TERENO_NETWORK=eip155:8453 # Base mainnet +``` ```ts -import terenoPlugin from "plugin-tereno"; - -// register like any ElizaOS plugin -agent(character, [terenoPlugin]); +import { terenoPlugin } from "plugin-tereno"; +// add terenoPlugin to your ElizaOS character's plugins, then ask the agent: +// is it safe to sign {"from":"0x…","to":"0x…","data":"0x…"}? +// β†’ returns a TerenoVerdict with recommendedAction, no USDC spent. ``` -```env -# the wallet that pays β€” needs USDC on Base (falls back to EVM_PRIVATE_KEY) -TERENO_PRIVATE_KEY=0x... +A complete runnable agent lives in [`examples/eliza-base-agent`](./examples/eliza-base-agent). -# hard budget guards (defaults shown) -TERENO_MAX_PRICE_USD=0.005 -TERENO_DAILY_BUDGET_USD=0.05 +### Going live -# recommended for public-facing agents: strangers can't spend your USDC -TERENO_PRIVATE_ONLY=true +```bash +TERENO_DRY_RUN=false +TERENO_PRIVATE_KEY=0x… # a dedicated hot wallet with a little USDC on Base +TERENO_MAX_PRICE_USD=0.005 # per-call cap +TERENO_DAILY_BUDGET_USD=0.05 # rolling daily cap +TERENO_FAIL_MODE=closed # financial checks abort if the guard can't answer ``` +> **Networks.** The hosted Tereno API (`www.tereno.xyz`) serves **Base mainnet**. +> To try it on **Base Sepolia**, point `TERENO_API_URL` at a Tereno instance +> configured with `X402_NETWORK=eip155:84532` (self-host) and set +> `TERENO_NETWORK=eip155:84532`. Dry-run works on any network with no wallet. + Then just talk to your agent: > **you:** is it safe to sign this? `{"from":"0x…","to":"0x…","data":"0x095ea7b3…"}` -> **agent:** ⚠️ review β€” UNLIMITED_ALLOWANCE. The spender gets max approval. Paid $0.005 Β· receipt attached. -> -> **you:** what is the network asking for? -> **agent:** 3 open bounties. Seeding the top one costs me only RPC work and earns $0.0002/reuse. -> -> **you:** seed it -> **agent:** 🌱 Fingerprint published, audit passed. I'm the seeder now. +> **agent:** β›” BLOCK Β· risk high Β· β†’ abort β€” UNLIMITED_ALLOWANCE: the spender gets max approval. Paid $0.005 Β· receipt attached. -## βš™οΈ All configuration +## βš™οΈ Configuration | Env | Default | Purpose | |---|---|---| -| `TERENO_PRIVATE_KEY` | β€” | Paying wallet (or `EVM_PRIVATE_KEY`) | -| `TERENO_MAX_PRICE_USD` | `0.005` | Per-call cap | -| `TERENO_DAILY_BUDGET_USD` | `0.05` | Rolling daily cap | -| `TERENO_PRIVATE_ONLY` | `false` | Refuse paid actions from public Discord/Telegram/Twitter | -| `TERENO_NETWORK` | `eip155:8453` | Base mainnet Β· `eip155:84532` for Sepolia | -| `TERENO_API_URL` | `https://www.tereno.xyz` | API origin | -| `TERENO_RPC_URL` | `https://mainnet.base.org` | RPC for local fingerprint work | +| `TERENO_PRIVATE_KEY` | β€” | Wallet that pays x402 (USDC on Base). Falls back to `EVM_PRIVATE_KEY`. | +| `TERENO_MAX_PRICE_USD` | `0.005` | Per-call USDC cap. | +| `TERENO_DAILY_BUDGET_USD` | `0.05` | Rolling daily USDC budget. | +| `TERENO_FAIL_MODE` | `closed` | `closed` β†’ financial checks abort on failure; `open` β†’ degrade to review. | +| `TERENO_DRY_RUN` | `false` | `true` β†’ no payment, simulated verdict. | +| `TERENO_PRIVATE_ONLY` | `false` | `true` β†’ refuse paid actions from public Discord/Telegram/Twitter. | +| `TERENO_NETWORK` | `eip155:8453` | `eip155:8453` (mainnet) or `eip155:84532` (Sepolia). | +| `TERENO_API_URL` | `https://www.tereno.xyz` | API origin. | +| `TERENO_RPC_URL` | `https://mainnet.base.org` | RPC for local fingerprint work. | +| `TERENO_TELEMETRY` | `on` | `off` β†’ send no anonymous usage telemetry. | + +### Fail-open vs fail-closed + +- **`closed` (default, for financial actions):** if the guard errors, times out, or + the daily budget is spent, `TERENO_CHECK_TRANSACTION` returns + `recommendedAction: "abort"`. The agent does **not** sign what it couldn't verify. +- **`open`:** the same situations degrade to `recommendedAction: "review"`. Even + open mode never returns `"sign"` on a failed check β€” a human or policy decides. +- Non-financial reads (token/chain/interface) always degrade to `review`. + +## πŸ” Privacy & telemetry + +The plugin **never transmits your private key, prompts, secrets or transaction +content** anywhere. The key is used locally to sign x402 payments and EIP-712 +messages only. + +Anonymous, opt-out telemetry (`TERENO_TELEMETRY=off` to disable) sends a strict +allowlist to help improve the network: plugin version, a **hashed** wallet +(`sha256`, never the address), network, check type, whether the call completed and +was paid, its USD cost, cache-hit, a repeat flag, and an integration-error flag. +Nothing else. See [`SECURITY.md`](./SECURITY.md). + +--- + +## 🌱 How it stays cheap β€” the cooperative layer (advanced) + +Tereno is a **shared-cache coordination layer**, not just an API. The first agent +to pay for a given piece of verified work *seeds* it for the whole network; every +later agent that reuses that answer pays less, and the seeder earns a small, +non-transferable credit that discounts its own future checks. + +The game theory: safety is a public good with a private cost. By making the first +computation earn from every reuse, the network turns *"I paid to check this +contract"* into *"I lowered the price of checking it for everyone, and my own +checks got cheaper."* More agents β‡’ deeper cache β‡’ cheaper, faster verdicts β‡’ +more agents. + +Advanced actions expose this loop β€” you never need them to use the security +checks above: + +| Action | Cost | What it does | +|---|---:|---| +| `TERENO_BOUNTIES` | free | See the work the network will pay to have seeded next | +| `TERENO_SEED` | free/$0.001 | Seed a bounty (publish a signed fingerprint, or be first payer) | +| `TERENO_STANDING` | free | The agent's audited track record and earned credits | +| `TERENO_REPORT_OUTCOME` | free | Attach a signed outcome to a settled verdict | + +> The honest fine print: rewards are **non-transferable credits**, capped per +> artifact β€” they fund your agent's safety budget, they are not yield. Reputation +> is computed by the network from recomputation audits and settled economics, +> never from claims. + +Discovery, scoring, reputation, pricing, the global cache and cooperation logic +live in the **closed Tereno backend**. This plugin and its SDK +([`tereno-client`](https://www.npmjs.com/package/tereno-client)) are the public, +open interface to it. ## 🀝 Support & community -- πŸ’¬ **Questions / setup help** β†’ [Tereno Discord](https://discord.gg/YsgGSRBxn) +- πŸ’¬ **Questions / setup** β†’ [Tereno Discord](https://discord.gg/YsgGSRBxn) - πŸ› **Bugs** β†’ [issues](https://github.com/RobMartello/plugin-tereno/issues) Β· ⚠️ never paste private keys or `.env` - πŸ”’ **Vulnerabilities** β†’ [report privately](https://github.com/RobMartello/plugin-tereno/security/advisories/new) Β· [SECURITY.md](SECURITY.md) -- πŸ—£οΈ **Ideas & show-and-tell** β†’ [Discussions](https://github.com/RobMartello/plugin-tereno/discussions) ## πŸ”— Links [Live catalog](https://www.tereno.xyz/api/v1/capabilities) Β· [OpenAPI](https://www.tereno.xyz/openapi.yaml) Β· -[Pricing & reuse economics](https://www.tereno.xyz/pricing) Β· +[tereno-client SDK](https://www.npmjs.com/package/tereno-client) Β· [Open bounties](https://www.tereno.xyz/api/v1/bounties) ---
-**Built on [Base](https://base.org) Β· Paid with [x402](https://www.x402.org) Β· Made for agents, by agents' humans** +**[tereno.xyz](https://www.tereno.xyz)** Β· Verdicts before signatures, for agents on Base.
diff --git a/REPOSITIONING-PLAN.md b/REPOSITIONING-PLAN.md new file mode 100644 index 0000000..32311da --- /dev/null +++ b/REPOSITIONING-PLAN.md @@ -0,0 +1,128 @@ +# plugin-tereno β€” Repositioning as a pre-signature security layer (plan) + +> Short plan requested before any code change. Scope: reposition `plugin-tereno` +> as a **pre-signature security layer for ElizaOS agents on Base**. Tereno's +> backend stays a closed system (discovery, scoring, reputation, pricing, global +> cache, cooperation, intelligence remain private). The plugin + SDK stay public. +> **No change to Tereno's core architecture; nothing outside this scope.** + +## Current state (as-is) + +- One file: `src/index.ts` β€” 11 actions. Depends on `tereno-client` (npm `^0.1.0`). +- **Security actions** (the real product): `TERENO_CHECK_TRANSACTION` (hero, `transaction.intent.guard`), `TERENO_CHECK_CONTRACT`, `TERENO_CONTRACT_CHANGED`, `TERENO_TOKEN_INFO`, `TERENO_CONTRACT_INTERFACE`, `TERENO_CHAIN_SNAPSHOT`, `TERENO_CATALOG` (free). +- **Earn/marketplace actions** (to demote, not delete): `TERENO_BOUNTIES`, `TERENO_SEED`, `TERENO_STANDING`, `TERENO_REPORT_OUTCOME`. +- README leads with *"income while it sleeps"* / *"earn its own safety budget"* β€” marketplace/passive-income framing at the top. +- Budget caps already exist: `TERENO_MAX_PRICE_USD` (per call, 0.005) + `TERENO_DAILY_BUDGET_USD` (rolling, 0.05). +- Gaps: **no tests, no CI, no `/examples`, no Sepolia quickstart, no dry-run, non-standard verdict return, no telemetry, no registry images/metadata polish.** + +## Target positioning + +Hero use case: *before an agent signs a Base transaction, it buys a bounded +verdict that flags unlimited approvals, suspicious contracts, unexpected balance +changes and abnormal gas.* The cooperative/earn loop moves to an **"Advanced / +how it stays cheap"** section near the bottom (game theory, shared cache) β€” kept +for the game-theory story you want at the end, removed from the top. + +--- + +## Changes β†’ affected files (your 12 items) + +| # | Item | Files | +|---|---|---| +| 1 | README: security-first | `README.md` | +| 2 | Remove marketplace/passive-income from the top | `README.md` | +| 3 | Working quickstart on **Base Sepolia** | `README.md`, `examples/eliza-base-agent/` | +| 4 | Dry-run mode **or** free first call | `src/index.ts` (new `TERENO_DRY_RUN` + free `TERENO_PREFLIGHT_ECHO`), `README.md` | +| 5 | Standard verdict: `decision, riskLevel, reason, exposure, cost, recommendedAction` | `src/index.ts` (new `normalizeVerdict()` + exported type) | +| 6 | Per-call + daily cost caps | already present β€” **document + surface in verdict `cost`**; no logic change | +| 7 | Document fail-open / fail-closed; **fail-closed default for financial** | `src/index.ts` (`TERENO_FAIL_MODE`, default `closed`), `README.md`, `SECURITY.md` | +| 8 | Full example | `examples/eliza-base-agent/` (character, `.env.example`, README, run script) | +| 9 | Anonymous telemetry (init, calls, distinct wallets, paid calls, recurrence, cache hit) | `src/index.ts` (opt-out beacon), **tereno**: additive `POST /api/v1/telemetry` + admin panel | +| 10 | Never capture private keys / full prompts / sensitive data | telemetry payload allowlist (hashed wallet only) | +| 11 | Registry metadata, images, tests, build, docs | `package.json` (`agentConfig`, keywords, images), `images/`, `src/*.test.ts`, CI `.github/workflows/` | +| 12 | Issues + small commits | GitHub issues (list below), one commit per item | + +### 5 β€” Standard verdict shape (mapped from Tereno `CapabilityResult`) + +```ts +interface TerenoVerdict { + decision: "allow" | "review" | "block"; // ← verdict + riskLevel: "none" | "low" | "medium" | "high"; // ← from reasonCodes/risks + reason: string; // ← result.summary + reasonCodes + exposure: { balanceDeltas?; allowanceChanges?; unlimitedApproval?: boolean }; + cost: { paidUsd: number; capUsd: number; dailySpentUsd: number }; + recommendedAction: "sign" | "review" | "abort"; + raw: CapabilityResult; // full passthrough, unchanged +} +``` +`normalizeVerdict()` derives these from the existing response; `raw` preserves +today's payload so nothing breaks for current consumers. + +### 4 + 7 β€” Dry-run / free-first + fail modes + +- `TERENO_DRY_RUN=true` β†’ actions build the exact input and return a **simulated + verdict** (no x402 payment), so integrators wire the flow for free. +- Free first touch: `TERENO_CATALOG` stays free; add a free local **echo/preview** + of what a check would cost and cover before the first paid call. +- `TERENO_FAIL_MODE`: `closed` (default) β†’ on guard error/timeout/budget-exhaustion, + `recommendedAction: "abort"` for `TERENO_CHECK_TRANSACTION`; `open` β†’ `"review"`. + Non-financial reads (token/chain) default fail-open. + +### 9 + 10 β€” Telemetry (privacy-preserving) + +**Plugin side** (opt-out `TERENO_TELEMETRY=off`): fire-and-forget `POST /api/v1/telemetry` +with a strict allowlist: +``` +pluginVersion, walletHash (sha256(address)), checkType, network, +completed (bool), paid (bool), costUsd, cacheHit (bool), repeat (bool), error (enum|null) +``` +**Never sent:** private key, full prompt, secrets, sensitive/unnecessary tx content +(only `checkType`, never `data`/`value`). Also send `x-tereno-client: plugin-tereno@` +on invoke calls so the backend can attribute paid settlements to the plugin. + +**Tereno side (additive, no core change):** new `POST /api/v1/telemetry` route that +records the allowlisted event, + an admin panel deriving the metric set. Paid +metrics come from the existing settlement ledger (`payer`, `capability`, `priceUsd`). + +**Metrics** (primary = **distinct external wallets with paid calls**): +`npm_downloads` (npm API), `plugin_initializations`, `catalog_calls`, +`preflight_checks`, `distinct_wallets`, `paid_calls`, `repeat_buyers_7d`, +`cache_hits_cross_wallet`, `blocked_transactions`, `false_positive_reports`. +Source per metric: ledger (paid_*, distinct wallets, repeat, cache, blocked) vs +beacon (init, catalog, preflight attempts, integration errors, false positives). + +--- + +## Risks + +- **Breaking output shape (#5):** current consumers read the text/`data` verdict. + Mitigation: keep `raw` passthrough + additive fields; bump minor version, note in README. +- **Sepolia quickstart (#3):** needs testnet USDC + a faucet path; verify the guard + is deployed on `eip155:84532` and prices resolve there. +- **Telemetry privacy (#9/#10):** any field beyond the allowlist is a leak. Mitigation: + a single typed payload builder + a test asserting no address/key/prompt is included. +- **Fail-closed default (#7):** changing default behavior could surprise integrators + who relied on best-effort. Mitigation: document loudly; it only strengthens safety. +- **Attribution tag:** header must not carry the wallet in clear beyond what x402 already + reveals (payer is public on-chain anyway); telemetry wallet is **hashed**. +- **Cross-repo coordination (#9):** the Tereno route is additive and shippable + independently; the plugin degrades gracefully if the route is absent. + +## Execution order (small commits, one issue each) + +1. **README rewrite** β€” security-first hero; demote earn loop to bottom game-theory section. *(docs only, zero risk)* +2. **Standard verdict** β€” `normalizeVerdict()` + exported `TerenoVerdict`, keep `raw`. *(+tests)* +3. **Fail modes + dry-run + free preview** β€” `TERENO_FAIL_MODE`, `TERENO_DRY_RUN`. +4. **Cost caps surfacing** β€” expose caps in `verdict.cost`, document. *(no logic change)* +5. **`examples/eliza-base-agent/`** β€” full runnable Sepolia example. +6. **Tests + build + CI** β€” `bun test`, `.github/workflows/ci.yml`. +7. **Registry polish** β€” `package.json` `agentConfig`/keywords, `images/`, description. +8. **Telemetry beacon (plugin)** β€” opt-out, hashed wallet, allowlist + privacy test. +9. **Tereno (additive)** β€” `POST /api/v1/telemetry` + admin metrics panel + client-tag attribution. +10. **Issues** β€” open the 9 issues (one per step 1–9) and land each as its own commit/PR. + +## Explicitly out of scope +- No change to Tereno discovery/scoring/reputation/pricing/global-cache/cooperation + algorithms (stay private in the backend). +- No new capabilities, no marketplace features, no removal of the earn loop + (only demoted in docs). diff --git a/SECURITY.md b/SECURITY.md index eef0d52..275f992 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,6 +22,14 @@ hours. vulnerability. - With `TERENO_PRIVATE_ONLY=true`, paid actions must refuse public Discord/Telegram/Twitter sources. Bypass is a vulnerability. +- **Fail-closed by default.** With `TERENO_FAIL_MODE=closed` (default), a + financial check that cannot settle (error, timeout, exhausted budget) returns + `recommendedAction: "abort"`. No mode ever returns `"sign"` on a failed check. +- **Telemetry is a strict allowlist.** When enabled (opt-out via + `TERENO_TELEMETRY=off`), the ONLY fields transmitted are: `pluginVersion`, + `walletHash` (sha256, never the address), `network`, `event`, `checkType`, + `completed`, `paid`, `costUsd`, `cacheHit`, `repeat`, `error`. Any private key, + prompt, secret or transaction content appearing in telemetry is a vulnerability. - Server-side issues (the Tereno API itself) can also be reported here; they will be routed to the platform. diff --git a/examples/eliza-base-agent/.env.example b/examples/eliza-base-agent/.env.example new file mode 100644 index 0000000..2d29ae4 --- /dev/null +++ b/examples/eliza-base-agent/.env.example @@ -0,0 +1,26 @@ +# ── plugin-tereno example: a Base agent that checks before it signs ── + +# Start free: dry-run makes every check simulate with NO x402 payment and NO wallet. +TERENO_DRY_RUN=true + +# Network. Hosted Tereno (www.tereno.xyz) serves Base MAINNET. +# For Base Sepolia, run your own Tereno with X402_NETWORK=eip155:84532 and set +# TERENO_API_URL to it, plus TERENO_NETWORK=eip155:84532. +TERENO_NETWORK=eip155:8453 +# TERENO_API_URL=https://www.tereno.xyz + +# ── Going live (set TERENO_DRY_RUN=false first) ── +# Dedicated hot wallet with a little USDC on Base. NEVER your main wallet. +# TERENO_PRIVATE_KEY=0xyour_dedicated_hot_wallet_key +TERENO_MAX_PRICE_USD=0.005 # per-call USDC cap +TERENO_DAILY_BUDGET_USD=0.05 # rolling daily USDC cap +TERENO_FAIL_MODE=closed # financial checks abort if the guard can't answer +TERENO_PRIVATE_ONLY=true # strangers in public channels can't spend your USDC + +# Anonymous, opt-out usage telemetry (hashed wallet only, never keys/prompts). +# TERENO_TELEMETRY=off + +# Your model provider key (any ElizaOS-supported provider), e.g.: +# ANTHROPIC_API_KEY= +# OPENAI_API_KEY= +# GOOGLE_GENERATIVE_AI_API_KEY= diff --git a/examples/eliza-base-agent/README.md b/examples/eliza-base-agent/README.md new file mode 100644 index 0000000..f8deb1b --- /dev/null +++ b/examples/eliza-base-agent/README.md @@ -0,0 +1,55 @@ +# Example: a Base agent that checks before it signs + +**Sentinel** is a minimal ElizaOS agent that runs a [Tereno](https://www.tereno.xyz) +pre-signature check before every transaction or contract interaction, and obeys +the verdict's `recommendedAction` (`sign` / `review` / `abort`). + +It starts in **dry-run** β€” every check simulates with **no payment and no wallet** β€” +so you can see the whole flow for free before spending a cent. + +## Run it + +```bash +# 1. install the plugin in your ElizaOS project +bun add plugin-tereno + +# 2. configure +cp .env.example .env +# leave TERENO_DRY_RUN=true for the free walkthrough + +# 3. start ElizaOS with this character +elizaos start --character ./character.json +``` + +Then talk to the agent: + +``` +you: what can you check and what does it cost? +Sentinel: (TERENO_PREVIEW) coverage + current config β€” free + +you: is it safe to sign {"from":"0x…","to":"0x…","data":"0x095ea7b3…"}? +Sentinel: (TERENO_CHECK_TRANSACTION) πŸ§ͺ dry-run verdict β€” no USDC spent +``` + +## Going live + +1. Set `TERENO_DRY_RUN=false`. +2. Set `TERENO_PRIVATE_KEY` to a **dedicated hot wallet** (never your main one) + holding a little USDC on Base. +3. Keep the budget caps and `TERENO_FAIL_MODE=closed` so a failed check aborts + instead of signing blind. + +## The verdict your agent branches on + +```ts +{ decision, riskLevel, reason, exposure, cost, recommendedAction, receiptUrl, raw } +``` + +`recommendedAction` is the one field to branch on: `sign` β†’ proceed, `review` β†’ +confirm with a human, `abort` β†’ refuse. + +## Networks + +The hosted Tereno API serves **Base mainnet** (`eip155:8453`). For **Base Sepolia**, +point `TERENO_API_URL` at a Tereno instance running `X402_NETWORK=eip155:84532` +and set `TERENO_NETWORK=eip155:84532`. Dry-run works on any network with no wallet. diff --git a/examples/eliza-base-agent/character.json b/examples/eliza-base-agent/character.json new file mode 100644 index 0000000..e180a54 --- /dev/null +++ b/examples/eliza-base-agent/character.json @@ -0,0 +1,31 @@ +{ + "name": "Sentinel", + "bio": [ + "A Base-native ElizaOS agent that never signs blind.", + "Runs every transaction and contract through a Tereno pre-signature check first." + ], + "system": "You are Sentinel, a careful on-chain agent on Base. Before signing any transaction or interacting with any contract, you ALWAYS run the matching Tereno check first (TERENO_CHECK_TRANSACTION for a tx, TERENO_CHECK_CONTRACT for an address). You obey the verdict's recommendedAction: on 'abort' you refuse and explain why; on 'review' you surface the risk and ask for confirmation; only on 'sign' do you proceed. You never reveal private keys or environment values. State the cost of each check.", + "plugins": ["plugin-tereno"], + "settings": { + "TERENO_DRY_RUN": "true", + "TERENO_NETWORK": "eip155:8453", + "TERENO_MAX_PRICE_USD": "0.005", + "TERENO_DAILY_BUDGET_USD": "0.05", + "TERENO_FAIL_MODE": "closed", + "TERENO_PRIVATE_ONLY": "true" + }, + "messageExamples": [ + [ + { "name": "operator", "content": { "text": "is it safe to sign {\"from\":\"0xabc...\",\"to\":\"0xdef...\",\"data\":\"0x095ea7b3...\"}?" } }, + { "name": "Sentinel", "content": { "text": "Running the Tereno pre-sign check…", "actions": ["TERENO_CHECK_TRANSACTION"] } } + ], + [ + { "name": "operator", "content": { "text": "what can you check and what does it cost?" } }, + { "name": "Sentinel", "content": { "text": "Here is my Tereno coverage and current config.", "actions": ["TERENO_PREVIEW"] } } + ] + ], + "style": { + "all": ["Be precise about risk", "Always state the check cost", "Never sign what you could not verify"], + "chat": ["Lead with the verdict and recommendedAction"] + } +} diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000..eef0051 Binary files /dev/null and b/images/logo.png differ diff --git a/package.json b/package.json index a08df99..da651f0 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,31 @@ { "name": "plugin-tereno", - "version": "0.1.0", - "description": "ElizaOS plugin for Tereno: paid Base preflight verdicts and chain-data reads over x402 (USDC), with per-call and daily budget caps.", + "version": "0.2.0", + "description": "Pre-signature security layer for ElizaOS agents on Base: buy a bounded verdict (unlimited-approval, drainer, suspicious-contract, balance/gas anomaly) before signing. Budget-capped, fail-closed, standardized verdict, dry-run.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ "dist", "README.md", - "LICENSE" + "LICENSE", + "images" ], "scripts": { "build": "tsc -p tsconfig.json", + "test": "bun test", "prepack": "npm run build" }, "keywords": [ "elizaos", "eliza", "plugin", + "security", + "pre-signature", + "transaction-safety", + "wallet-security", + "preflight", + "guard", "x402", "base", "usdc", @@ -50,6 +58,18 @@ "TERENO_NETWORK": { "type": "string", "description": "eip155:8453 (Base mainnet, default) or eip155:84532 (Base Sepolia)." + }, + "TERENO_FAIL_MODE": { + "type": "string", + "description": "closed (default) makes financial checks abort when the guard cannot answer; open degrades to review. Never auto-signs on failure." + }, + "TERENO_DRY_RUN": { + "type": "string", + "description": "Set to true to simulate checks with no x402 payment (works with no wallet)." + }, + "TERENO_TELEMETRY": { + "type": "string", + "description": "Set to off to disable anonymous, allowlisted usage telemetry (hashed wallet only; never keys/prompts)." } } }, diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..b98b080 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + normalizeVerdict, + failVerdict, + telemetryPayload, + TELEMETRY_KEYS, +} from "./index.ts"; + +const COST = { capUsd: 0.005, dailySpentUsd: 0.01 }; +const BASE = "https://www.tereno.xyz"; + +const originalEnv = { ...process.env }; +afterEach(() => { + for (const k of Object.keys(process.env)) + if (!(k in originalEnv)) delete process.env[k]; + Object.assign(process.env, originalEnv); +}); + +describe("normalizeVerdict", () => { + test("allow β†’ sign / risk none", () => { + const v = normalizeVerdict( + { verdict: "allow", result: { summary: "ok" }, pricing: { pricePaidUsd: 0.005 } } as never, + COST, + BASE, + ); + expect(v.decision).toBe("allow"); + expect(v.recommendedAction).toBe("sign"); + expect(v.riskLevel).toBe("none"); + expect(v.cost.paidUsd).toBe(0.005); + }); + + test("block β†’ abort / risk high", () => { + const v = normalizeVerdict({ verdict: "block", result: { summary: "drainer" } } as never, COST, BASE); + expect(v.decision).toBe("block"); + expect(v.recommendedAction).toBe("abort"); + expect(v.riskLevel).toBe("high"); + }); + + test("unknown verdict is treated as review (fail-safe)", () => { + const v = normalizeVerdict({ result: {} } as never, COST, BASE); + expect(v.decision).toBe("review"); + expect(v.recommendedAction).toBe("review"); + }); + + test("unlimited-approval reason code flips exposure + medium risk", () => { + const v = normalizeVerdict( + { verdict: "review", result: { reasonCodes: ["UNLIMITED_ALLOWANCE"] } } as never, + COST, + BASE, + ); + expect(v.exposure.unlimitedApproval).toBe(true); + expect(v.riskLevel).toBe("medium"); + }); + + test("a high-severity risk yields high risk level", () => { + const v = normalizeVerdict( + { verdict: "review", result: { risks: [{ severity: "high", code: "PROXY_UPGRADE" }] } } as never, + COST, + BASE, + ); + expect(v.riskLevel).toBe("high"); + }); + + test("preserves the raw payload and builds a receipt url", () => { + const raw = { verdict: "allow", receiptUrl: "/api/v1/receipts/abc" }; + const v = normalizeVerdict(raw as never, COST, BASE); + expect(v.raw).toBe(raw as never); + expect(v.receiptUrl).toBe(`${BASE}/api/v1/receipts/abc`); + }); +}); + +describe("failVerdict (fail-open / fail-closed)", () => { + test("financial + closed (default) β†’ abort", () => { + const v = failVerdict("guard down", true); + expect(v.decision).toBe("block"); + expect(v.recommendedAction).toBe("abort"); + expect(v.cost.paidUsd).toBe(0); // nothing charged + }); + + test("financial + open β†’ review (never auto-sign)", () => { + process.env.TERENO_FAIL_MODE = "open"; + const v = failVerdict("guard down", true); + expect(v.recommendedAction).toBe("review"); + expect(v.recommendedAction).not.toBe("sign"); + }); + + test("non-financial read β†’ review", () => { + const v = failVerdict("guard down", false); + expect(v.recommendedAction).toBe("review"); + }); +}); + +describe("telemetryPayload (privacy allowlist)", () => { + test("emits exactly the allowlisted keys, nothing else", () => { + const p = telemetryPayload( + { event: "check", checkType: "transaction.intent.guard", completed: true, paid: true, costUsd: 0.005 }, + "deadbeefhash", + "eip155:8453", + ); + expect(Object.keys(p).sort()).toEqual([...TELEMETRY_KEYS].sort()); + }); + + test("never contains a raw address, key, or prompt", () => { + const p = telemetryPayload( + { event: "check", checkType: "contract.guard" }, + "9f86d081884c7d659a2feaa0c55ad015", // sha256-style hash, not an address + "eip155:8453", + ); + const serialized = JSON.stringify(p); + expect(serialized).not.toMatch(/0x[a-fA-F0-9]{40}/); // no EVM address + expect(serialized).not.toMatch(/0x[a-fA-F0-9]{64}/); // no private key + expect(p.walletHash).toBe("9f86d081884c7d659a2feaa0c55ad015"); + }); + + test("carries the plugin version for attribution", () => { + const p = telemetryPayload({ event: "init" }, null, "eip155:8453"); + expect(typeof p.pluginVersion).toBe("string"); + }); +}); diff --git a/src/index.ts b/src/index.ts index b1b0a85..0e0f50d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import type { State, } from "@elizaos/core"; import { logger } from "@elizaos/core"; +import { createHash } from "node:crypto"; import { createPublicClient, http, keccak256, type Hex } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { base, baseSepolia } from "viem/chains"; @@ -51,6 +52,134 @@ function underDailyBudget(nextPriceUsd: number): boolean { return spentTodayUsd + nextPriceUsd <= limit; } +// Fail mode (issue #4). closed (default) = a failed/uncertain check on a +// FINANCIAL action recommends abort; open = it degrades to review (never +// auto-"sign"). Non-financial reads always degrade to review. +function failClosed(): boolean { + return env("TERENO_FAIL_MODE", "closed").toLowerCase() !== "open"; +} + +// Dry-run (issue #5): wire the flow for free, no x402 payment. +function dryRunEnabled(): boolean { + return env("TERENO_DRY_RUN").toLowerCase() === "true"; +} + +/** A synthetic verdict for dry-run / fail-mode paths (no on-chain settlement). */ +function syntheticVerdict(fields: { + decision: Decision; + riskLevel: RiskLevel; + reason: string; + recommendedAction: RecommendedAction; +}): TerenoVerdict { + return { + ...fields, + exposure: { unlimitedApproval: false }, + cost: { paidUsd: 0, capUsd: maxPriceUsd(), dailySpentUsd: spentTodayUsd }, + receiptUrl: null, + raw: {}, + }; +} + +/** What to return when a check cannot be settled (error/timeout/budget). */ +export function failVerdict(reason: string, financial: boolean): TerenoVerdict { + const abort = financial && failClosed(); + return syntheticVerdict({ + decision: abort ? "block" : "review", + riskLevel: abort ? "high" : "medium", + reason: `${reason} (fail-${failClosed() ? "closed" : "open"})`, + recommendedAction: abort ? "abort" : "review", + }); +} + +// β€”β€”β€”β€”β€” Anonymous telemetry (issues #9/#10) β€”β€”β€”β€”β€” +// Opt-out (TERENO_TELEMETRY=off). STRICT allowlist below β€” this is the ONLY +// data ever transmitted. Never the private key, prompt, secrets or tx content. +const PLUGIN_VERSION = "0.2.0"; + +function telemetryEnabled(): boolean { + return env("TERENO_TELEMETRY").toLowerCase() !== "off"; +} + +/** sha256(address) truncated β€” anonymized wallet, never the address in clear. */ +function walletHash(): string | null { + const wallet = agentWallet(); + return wallet + ? createHash("sha256").update(wallet).digest("hex").slice(0, 32) + : null; +} + +/** Best-effort cross-wallet cache-hit signal from the raw response. */ +function readCacheHit(data: VerdictResult): boolean { + const raw = data as unknown as Record; + const status = (data.result as Record | undefined)?.[ + "cacheStatus" + ]; + return status === "hit" || raw["cached"] === true; +} + +let paidCallCount = 0; + +export interface TelemetryEvent { + event: "init" | "check"; + checkType?: string; + completed?: boolean; + paid?: boolean; + costUsd?: number; + cacheHit?: boolean; + repeat?: boolean; + error?: string | null; +} + +/** The EXACT set of fields ever transmitted. Anything not here is a leak. */ +export const TELEMETRY_KEYS = [ + "pluginVersion", "walletHash", "network", "event", "checkType", + "completed", "paid", "costUsd", "cacheHit", "repeat", "error", +] as const; + +/** Pure allowlisted payload builder (issue #10). Testable; no PII/keys/prompts. */ +export function telemetryPayload( + e: TelemetryEvent, + walletHashValue: string | null, + network: string, +): Record { + return { + pluginVersion: PLUGIN_VERSION, + walletHash: walletHashValue, + network, + event: e.event, + checkType: e.checkType ?? null, + completed: e.completed ?? null, + paid: e.paid ?? null, + costUsd: e.costUsd ?? null, + cacheHit: e.cacheHit ?? null, + repeat: e.repeat ?? null, + error: e.error ?? null, + }; +} + +/** Fire-and-forget anonymized beacon. Never blocks or throws into the loop. */ +function sendTelemetry(e: TelemetryEvent): void { + if (!telemetryEnabled()) return; + const baseUrl = env("TERENO_API_URL", "https://www.tereno.xyz"); + const url = env("TERENO_TELEMETRY_URL", `${baseUrl}/api/v1/telemetry`); + const payload = telemetryPayload( + e, + walletHash(), + env("TERENO_NETWORK", "eip155:8453"), + ); + void fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-tereno-client": `plugin-tereno@${PLUGIN_VERSION}`, + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(4000), + }).catch(() => { + /* telemetry must never affect the agent */ + }); +} + type TerenoAccount = NonNullable[0]["account"]>; function client() { @@ -79,21 +208,115 @@ function sourceAllowed(message: Memory): boolean { return source !== "discord" && source !== "telegram" && source !== "twitter"; } +interface Risk { + code?: string; + severity?: "low" | "medium" | "high" | string; + title?: string; +} + interface VerdictResult { verdict?: string; - result?: { summary?: string; reasonCodes?: string[]; agentNote?: string; validUntilBlock?: number }; + invocationId?: string; + result?: { + summary?: string; + reasonCodes?: string[]; + agentNote?: string; + validUntilBlock?: number; + risks?: Risk[]; + balanceDeltas?: unknown; + allowanceChanges?: unknown; + }; pricing?: { pricePaidUsd?: number }; receiptUrl?: string; } -function formatVerdict(data: VerdictResult, baseUrl: string): string { - const icon = data.verdict === "allow" ? "βœ…" : data.verdict === "review" ? "⚠️" : "β›”"; +// β€”β€”β€”β€”β€” Standardized verdict (issue #3) β€”β€”β€”β€”β€” +// Every guard action returns this stable shape so integrators branch on one +// contract. `raw` preserves the full Tereno CapabilityResult unchanged. +export type Decision = "allow" | "review" | "block"; +export type RiskLevel = "none" | "low" | "medium" | "high"; +export type RecommendedAction = "sign" | "review" | "abort"; + +export interface TerenoVerdict { + decision: Decision; + riskLevel: RiskLevel; + reason: string; + exposure: { + balanceDeltas?: unknown; + allowanceChanges?: unknown; + unlimitedApproval: boolean; + }; + cost: { paidUsd: number; capUsd: number; dailySpentUsd: number }; + recommendedAction: RecommendedAction; + receiptUrl: string | null; + raw: VerdictResult; +} + +const UNLIMITED_APPROVAL_CODES = /unlimited|max_?approval|infinite/i; +const DRAINER_CODES = /drainer|sweep|malicious|scam/i; + +function toDecision(verdict: string | undefined): Decision { + return verdict === "allow" || verdict === "block" ? verdict : "review"; +} + +function toRiskLevel(data: VerdictResult, decision: Decision): RiskLevel { + const risks = data.result?.risks ?? []; + const severities = risks.map((r) => (r.severity || "").toLowerCase()); + if (severities.includes("high")) return "high"; + if (severities.includes("medium")) return "medium"; + const codes = (data.result?.reasonCodes ?? []).join(" "); + if (DRAINER_CODES.test(codes)) return "high"; + if (UNLIMITED_APPROVAL_CODES.test(codes)) return "medium"; + if (severities.includes("low") || (data.result?.reasonCodes?.length ?? 0) > 0) { + return "low"; + } + // No explicit risks: infer a floor from the decision. + return decision === "block" ? "high" : decision === "review" ? "low" : "none"; +} + +function toRecommendedAction(decision: Decision): RecommendedAction { + return decision === "allow" ? "sign" : decision === "block" ? "abort" : "review"; +} + +/** Map a raw Tereno CapabilityResult into the stable TerenoVerdict shape. */ +export function normalizeVerdict( + data: VerdictResult, + cost: { capUsd: number; dailySpentUsd: number }, + baseUrl: string, +): TerenoVerdict { + const decision = toDecision(data.verdict); + const codes = (data.result?.reasonCodes ?? []).join(" "); + const reason = [data.result?.summary, data.result?.reasonCodes?.join(", ")] + .filter(Boolean) + .join(" β€” ") || "no reason provided"; + return { + decision, + riskLevel: toRiskLevel(data, decision), + reason, + exposure: { + balanceDeltas: data.result?.balanceDeltas, + allowanceChanges: data.result?.allowanceChanges, + unlimitedApproval: UNLIMITED_APPROVAL_CODES.test(codes), + }, + cost: { + paidUsd: data.pricing?.pricePaidUsd ?? 0, + capUsd: cost.capUsd, + dailySpentUsd: cost.dailySpentUsd, + }, + recommendedAction: toRecommendedAction(decision), + receiptUrl: data.receiptUrl ? `${baseUrl}${data.receiptUrl}` : null, + raw: data, + }; +} + +function formatVerdict(v: TerenoVerdict): string { + const icon = v.decision === "allow" ? "βœ…" : v.decision === "review" ? "⚠️" : "β›”"; const lines = [ - `${icon} Tereno verdict: ${data.verdict ?? "unknown"}`, - data.result?.summary ?? null, - data.result?.reasonCodes?.length ? `Reasons: ${data.result.reasonCodes.join(", ")}` : null, - data.pricing?.pricePaidUsd !== undefined ? `Paid: $${data.pricing.pricePaidUsd} USDC (x402)` : null, - data.receiptUrl ? `Receipt: ${baseUrl}${data.receiptUrl}` : null, + `${icon} Tereno: ${v.decision.toUpperCase()} Β· risk ${v.riskLevel} Β· β†’ ${v.recommendedAction}`, + v.reason, + v.exposure.unlimitedApproval ? "⚠️ unlimited approval detected" : null, + `Paid: $${v.cost.paidUsd} USDC (x402) Β· cap $${v.cost.capUsd}/call Β· $${v.cost.dailySpentUsd.toFixed(3)} today`, + v.receiptUrl ? `Receipt: ${v.receiptUrl}` : null, ]; return lines.filter(Boolean).join("\n"); } @@ -104,6 +327,8 @@ function paidAction(config: { description: string; buildInput: (message: Memory) => CapabilityInput | null; missingInputText: string; + /** true for actions that gate a signature/spend (fail-closed by default). */ + financial?: boolean; }): Action { return { name: config.name, @@ -125,18 +350,67 @@ function paidAction(config: { const input = config.buildInput(message); if (!input) return respond(config.missingInputText, false); const perCall = maxPriceUsd(); + const financial = config.financial === true; + + // Dry-run (#5): validate + shape the flow without paying. + if (dryRunEnabled()) { + const v = syntheticVerdict({ + decision: "review", + riskLevel: "low", + reason: `dry-run: input accepted for ${input.capabilityId}; no x402 payment made. Set TERENO_DRY_RUN=false to buy the real verdict.`, + recommendedAction: "review", + }); + return respond(`πŸ§ͺ ${formatVerdict(v)}`, true, v); + } + if (!underDailyBudget(perCall)) { - return respond("Tereno daily USDC budget is exhausted; try again tomorrow or raise TERENO_DAILY_BUDGET_USD.", false); + sendTelemetry({ + event: "check", + checkType: input.capabilityId, + completed: false, + paid: false, + error: "budget_exhausted", + }); + const v = failVerdict("daily USDC budget exhausted", financial); + return respond(formatVerdict(v), false, v); } try { const tereno = client(); const { data } = await tereno.invokeCapability(input); - spentTodayUsd += data.pricing?.pricePaidUsd ?? perCall; - return respond(formatVerdict(data, env("TERENO_API_URL", "https://www.tereno.xyz")), true, data); + const paid = data.pricing?.pricePaidUsd ?? perCall; + spentTodayUsd += paid; + const repeat = paidCallCount > 0; + paidCallCount += 1; + sendTelemetry({ + event: "check", + checkType: input.capabilityId, + completed: true, + paid: true, + costUsd: paid, + cacheHit: readCacheHit(data), + repeat, + error: null, + }); + const verdict = normalizeVerdict( + data, + { capUsd: perCall, dailySpentUsd: spentTodayUsd }, + env("TERENO_API_URL", "https://www.tereno.xyz"), + ); + return respond(formatVerdict(verdict), true, verdict); } catch (error) { logger.warn({ error, action: config.name }, "Tereno capability call failed"); const reason = error instanceof Error ? error.message : String(error); - return respond(`Tereno call did not settle (nothing was charged): ${reason}`, false); + sendTelemetry({ + event: "check", + checkType: input.capabilityId, + completed: false, + paid: false, + error: "integration_error", + }); + // Nothing was charged (x402 settles only on success). Fail-mode decides + // whether a financial action aborts or degrades to review. + const v = failVerdict(`guard unavailable: ${reason}`, financial); + return respond(formatVerdict(v), false, v); } }, examples: [], @@ -153,6 +427,7 @@ const checkContract = paidAction({ return address ? { capabilityId: "contract.guard", address } : null; }, missingInputText: "I need a Base contract address (0x…) to run the Tereno contract check.", + financial: true, }); const tokenInfo = paidAction({ @@ -189,6 +464,7 @@ const contractChanged = paidAction({ return address ? { capabilityId: "contract.change", address } : null; }, missingInputText: "I need a Base contract address (0x…) to compare with Tereno's previous observation.", + financial: true, }); const chainSnapshot = paidAction({ @@ -223,6 +499,7 @@ const checkTransaction = paidAction({ }, missingInputText: 'To simulate before signing I need the unsigned transaction as JSON, e.g. {"from":"0x…","to":"0x…","data":"0x…","value":"0"}.', + financial: true, }); // β€”β€”β€”β€”β€” Cooperative loop: the agent earns its own safety budget β€”β€”β€”β€”β€” @@ -482,13 +759,51 @@ const catalogAction: Action = { examples: [], }; +const previewAction: Action = { + name: "TERENO_PREVIEW", + similes: ["TERENO_HELP", "WHAT_CAN_TERENO_CHECK", "TERENO_COVERAGE", "TERENO_COST"], + description: + "Free: explain what the Tereno security checks cover, what each costs, and the current safety config (per-call cap, daily budget, fail mode, dry-run). No payment, no wallet needed. Use to preview before spending.", + validate: async () => true, + handler: async ( + _runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: unknown, + callback: HandlerCallback | undefined, + ): Promise => { + const configured = privateKey() !== null; + const text = [ + "πŸ›‘οΈ Tereno β€” pre-signature safety for Base agents. Coverage:", + "β€’ TERENO_CHECK_TRANSACTION ($0.005) β€” is this safe to sign? simulation, gas, balance/allowance deltas, drainer & unlimited-approval flags", + "β€’ TERENO_CHECK_CONTRACT ($0.001–0.003) β€” is this contract safe to touch? code, proxy, pause", + "β€’ TERENO_CONTRACT_CHANGED ($0.001) β€” did it change since last look? (rug-after-audit)", + "β€’ TERENO_TOKEN_INFO / TERENO_CONTRACT_INTERFACE / TERENO_CHAIN_SNAPSHOT ($0.001) β€” reads", + "Every verdict returns: decision, riskLevel, reason, exposure, cost, recommendedAction.", + `Config: per-call cap $${maxPriceUsd()} Β· daily budget $${env("TERENO_DAILY_BUDGET_USD", "0.05")} Β· fail-${failClosed() ? "closed" : "open"}${dryRunEnabled() ? " Β· DRY-RUN (no payment)" : ""}`, + configured ? "Wallet configured β€” paid checks are available." : "No wallet configured yet β€” set TERENO_PRIVATE_KEY (Base USDC) to run paid checks; TERENO_DRY_RUN=true works with no wallet.", + ].join("\n"); + await callback?.({ text, actions: ["TERENO_PREVIEW"], source: message.content.source }); + return { success: true, text }; + }, + examples: [], +}; + export const terenoPlugin: Plugin = { name: "plugin-tereno", + // Anonymous init beacon (plugin_initializations). Never blocks startup. + init: async () => { + sendTelemetry({ event: "init" }); + }, description: - "Tereno for Base agents: buy bounded preflight verdicts and chain-data reads over x402 (budget-capped), and earn the agent's own safety budget by seeding work the network asks for β€” audited publishing, seeder dividends, portable reputation.", + "Pre-signature security layer for ElizaOS agents on Base: before an agent signs, buy a bounded verdict flagging unlimited approvals, suspicious contracts, unexpected balance changes and abnormal gas. Budget-capped (per-call + daily), fail-closed by default, standardized verdict, dry-run mode.", actions: [ + // Security first: the pre-signature guard is the hero. checkTransaction, checkContract, contractChanged, tokenInfo, contractInterface, chainSnapshot, - bountiesAction, seedAction, standingAction, reportOutcomeAction, catalogAction, + // Free helpers. + previewAction, catalogAction, + // Advanced / how it stays cheap (cooperative loop). + bountiesAction, seedAction, standingAction, reportOutcomeAction, ], }; diff --git a/tsconfig.json b/tsconfig.json index 0ce0bd6..0961635 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,8 @@ }, "include": [ "src/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts" ] }