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
+

-### Your agent's onchain instinct β verdicts before it signs, income while it sleeps
+# plugin-tereno
-[](https://www.npmjs.com/package/plugin-tereno)
-[](https://elizaos.ai)
-[](https://base.org)
-[](https://www.x402.org)
-[](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.
+
+
+
+[](https://www.npmjs.com/package/plugin-tereno)
+[](https://elizaos.ai)
+[](https://base.org)
+[](https://www.x402.org)
+
+[](https://github.com/RobMartello/plugin-tereno/actions)
+[](./LICENSE)
+[](#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"
]
}