Pharos Sentinel
by mattcrypted
README.md
# Sentinel โ a pre-action on-chain risk gate for Pharos agents
**๐ Live demo:** **[pharos-sentinel-production.up.railway.app](https://pharos-sentinel-production.up.railway.app)** โ run the risk gate against the live Atlantic fixtures (each `/check` executes real Foundry `cast` reads on Pharos Atlantic testnet).
This repository ships the **Pharos Skill Engine** with **Sentinel** added as its
**Step 0 โ risk pre-check**. The engine
([`PharosNetwork/pharos-skill-engine`](https://github.com/PharosNetwork/pharos-skill-engine))
gives an AI agent the full Pharos on-chain toolkit โ balance/transaction queries, transfers,
contract deploy & verify, and batch airdrops โ driven through Foundry (`cast` / `forge`).
**Sentinel** is the reusable Skill this repo adds on top: an agent calls it **before it moves
value** and gets a risk **verdict** (`safe` / `caution` / `dangerous`), the **reasons** behind
it, and a **risk-bounded execution plan**. It is read-only โ it advises and blocks; it never
signs or sends a transaction.
A pre-action risk check is the most-called primitive in any on-chain agent stack: every
transfer, swap, or approval is a place an agent can lose funds. Sentinel makes that check a
single, composable call, and wires it in as the engine's first write-operation pre-check.
## What's in this package
This is the Pharos Skill Engine layout, with Sentinel slotted in as a skill:
- **`SKILL.md`** โ the engine's agent entry point. Sentinel is registered in the Capability
Index and as **Step 0** of the Write-Operation Pre-checks.
- **`references/`** โ the engine's command references (`query.md`, `transaction.md`,
`contract.md`, `script-gen.md`) plus **`sentinel.md`**, the risk-gate reference.
- **`assets/`** โ the engine's `networks.json` (Atlantic testnet + mainnet), `tokens.json`,
ERC-20 / airdrop Solidity templates, and script-generation templates.
- **Sentinel runtime** โ `sentinel_skill.py` (MCP server), `sentinel_cli.py` (CLI),
`pharos_atlantic.py` (RPC reads), plus the demos, live risk gallery, x402 gate, and tests.
Sentinel runs on **Atlantic testnet** โ matching the engine's default network and its Piggy Bank
reference skill โ while mainnet stays available in `networks.json` for the engine's other
capabilities.
## What makes it different
- **Read-only by design.** Sentinel never holds keys and never sends a transaction โ the safest
possible posture for a Skill that agents trust before moving money.
- **Foundry-native execution.** Every on-chain read runs through the Foundry `cast` CLI โ the same
toolchain the rest of the Skill Engine uses โ so Sentinel composes into the engine rather than
bolting a separate client onto it. No indexer, no third-party API, no keys, no database.
- **Not just a verdict โ a plan.** `execution_plan` returns approve/block plus bounded sizing
within the caller's risk tolerance, so the agent gets a decision, not just a score.
- **Real EVM depth.** Bytecode opcode analysis and proxy/ownership introspection, not surface
heuristics (details below).
## How it works
Sentinel sits between an agent and the chain. The agent declares an intent; Sentinel reads
Pharos Atlantic by executing read-only Foundry `cast` commands (no keys, no transaction), scores
what it finds, and returns a verdict, the reasons, and a risk-gated plan. The agent acts on the plan.
```mermaid
flowchart LR
A["Pharos agent<br/>transfer ยท swap ยท approve ยท call"] -->|"address + action + amount"| S{{"Sentinel Skill<br/>risk_check / execution_plan"}}
S -->|"cast reads ยท no keys ยท no tx"| C[("Pharos Atlantic<br/>chainId 688689")]
C -->|"cast code ยท call ยท storage"| S
S -->|"verdict + reasons + plan"| D{verdict}
D -->|safe| P["Proceed"]
D -->|caution| R["Reduce size / confirm"]
D -->|dangerous| B["Block"]
```
A blocked `approve` under a strict safe-only tolerance, end to end (mirrors `demo_agent.py`):
```mermaid
sequenceDiagram
autonumber
participant Ag as Pharos agent
participant Se as Sentinel
participant Ch as Pharos Atlantic
Ag->>Se: risk_check(addr, "approve", 100)
Se->>Ch: cast code ยท cast call ยท cast storage
Ch-->>Se: bytecode ยท owner ยท impl slot ยท paused
Se-->>Ag: verdict "caution" + reasons
Ag->>Se: execution_plan(addr, "approve", 100, max_risk "safe")
Se-->>Ag: approved=false ยท BLOCK
Note over Ag: agent halts โ no allowance is signed
```
## Two tools
| Tool | Purpose |
|------|---------|
| `risk_check(address, action, amount_phrs?)` | Returns `{verdict, score, reasons[], data{}}`. |
| `execution_plan(address, action, amount_phrs, max_risk)` | Risk-gated: approve/block + bounded sizing within tolerance. |
`action` is one of `transfer` \| `swap` \| `approve` \| `call`.
## Risk signals (v2 โ read via Foundry `cast`)
- **Contract vs EOA**, and action/target mismatches (e.g. `approve` to a non-token, or to a wallet).
- **Proxy detection:** EIP-1167 minimal proxies and EIP-1967/1822 **upgradeable** proxies
(the owner can swap the logic after you interact โ a real rug vector).
- **Bytecode opcode analysis:** a proper opcode walk (stepping over PUSH immediates) that flags
**SELFDESTRUCT** and **DELEGATECALL** used outside a known proxy pattern.
- **Ownership & upgrade-admin concentration:** `owner()` + the EIP-1967 admin slot, distinguishing
an **EOA owner** (higher centralization risk) from a contract owner (likely multisig/timelock).
- **ERC-20 introspection:** `symbol` / `decimals` / `totalSupply`, with a zero-supply-trap flag.
- **Pausable state** (`paused()`), **tiny-bytecode stubs**, and brand-new / zero-history
counterparties (a typo & address-poisoning guard).
Every check starts at a perfect **100** and each signal subtracts from it, so risks stack into a
single **safety score** (100 = safest, 0 = riskiest). The verdict is a band over it
(`>=66` safe, `31โ65` caution, `<=30` dangerous). The band turns the score into the verdict, and
`execution_plan` turns that into a decision:
```mermaid
flowchart TD
addr["address + action"] --> isC{"has bytecode?"}
isC -->|"no โ EOA"| eoa["EOA signals<br/>fresh / no history ยท action/target mismatch"]
isC -->|"yes โ contract"| con["Contract signals"]
con --> px["proxy<br/>EIP-1167 ยท 1967 ยท 1822"]
con --> bc["bytecode<br/>SELFDESTRUCT ยท DELEGATECALL"]
con --> own["ownership / upgrade-admin<br/>EOA vs contract"]
con --> tok["ERC-20<br/>supply ยท zero-supply trap"]
con --> st["pausable ยท tiny stub"]
eoa --> sc(["safety score<br/>100 โ risks"])
px --> sc
bc --> sc
own --> sc
tok --> sc
st --> sc
sc --> band{"safety band"}
band -->|"<= 30"| dg["dangerous"]
band -->|"31 - 65"| ca["caution"]
band -->|">= 66"| sf["safe"]
dg --> plan["execution_plan<br/>approve / block + bounded size"]
ca --> plan
sf --> plan
```
## Use it two ways
**As an MCP server** โ for MCP-capable agents:
```bash
pip install -r requirements.txt
python sentinel_skill.py # stdio MCP server exposing risk_check + execution_plan
```
**As a framework Skill (SKILL.md) / CLI** โ for Claude Code / OpenClaw / Codex style agents:
```bash
python sentinel_cli.py <address> <action> # verdict (JSON)
python sentinel_cli.py <address> approve --plan --max-risk safe # risk-gated plan
```
The CLI exits `0` for safe/caution (or an approved plan) and `2` for dangerous/blocked, so a
shell or agent can branch on the exit status alone. See `SKILL.md` for the skill definition.
## Quickstart
Live reads require **Foundry** (`cast`) on your PATH (`curl -L https://foundry.paradigm.xyz | bash && foundryup`).
The offline tests and the `--synthetic` tour need neither Foundry nor network.
```bash
python -m unittest test_sentinel # 34 deterministic offline tests (no network, no Foundry)
python feature_tour.py --synthetic # walk every signal instantly (no network)
python demo_agent.py # an agent drives the Skill over MCP against live Atlantic
python -c "import pharos_atlantic as p; print('chain_ok:', p.chain_ok())"
```
## Sample output
```text
$ python sentinel_cli.py 0x24f3cd306c85903ca2ccd0ee8dc1c74111151b23 call
{
"verdict": "caution",
"score": 65,
"reasons": ["tiny bytecode (1 bytes) โ likely a stub/trap rather than a working contract"],
"data": { "is_contract": true, "code_size": 1 }
}
```
## Live demo
Drive five Sentinel features as five real Atlantic transactions, one command each:
```bash
python demo.py deploy # deploy a malicious contract -> Sentinel flags it DANGEROUS
python demo.py upgrade # swap a proxy's logic in one tx -> verdict escalates
python demo.py pause # pause a contract -> verdict escalates
python demo.py transfer # execution_plan sends real PHRS only when safe
python demo.py x402 # pay-per-query risk check over x402
python demo.py all # all five, with a transaction summary
```
Every run deploys fresh contracts and sends fresh transactions โ it's live, not a recording. Full
runbook (commands, suggested patter, expected output) in [`DEMO.md`](DEMO.md).
## Live on Pharos Atlantic
Sentinel integrates with **Pharos Atlantic Testnet** via live Foundry `cast` reads:
- RPC `https://atlantic.dplabs-internal.com` ยท chainId **688689** ยท explorer
`https://atlantic.pharosscan.xyz` ยท gas token **PHRS**
### Live risk gallery
To prove the engine against real bytecode (not mocks), a spectrum of decoy contracts was
**deployed on Atlantic**, each engineered to trip a different signal. Sentinel reads them live and
returns a monotonic safe โ caution โ dangerous ladder โ reproduce it yourself with `python gallery.py`:
| Exhibit | Action | Verdict | Score | Signal demonstrated |
|---|---|:--:|--:|---|
| [CleanToken](https://atlantic.pharosscan.xyz/address/0x63079724981B42967ee9F77E637767ac7779e181) | transfer | ๐ข safe | 0 | clean ERC-20, no privileged owner โ baseline, no false alarm |
| [MinimalProxy](https://atlantic.pharosscan.xyz/address/0x434d20f7211deaca55667d313b9e6035481ef6f5) | call | ๐ข safe | 10 | EIP-1167 minimal proxy detected |
| [TinyStub](https://atlantic.pharosscan.xyz/address/0x24f3cd306c85903ca2ccd0ee8dc1c74111151b23) | call | ๐ก caution | 35 | tiny-bytecode stub / trap |
| [ZeroSupplyToken](https://atlantic.pharosscan.xyz/address/0x59A0cb6350D93714e557Ea0ddC2e60d1aD558dc5) | transfer | ๐ก caution | 40 | zero-supply token trap + EOA owner |
| [UpgradeableProxy](https://atlantic.pharosscan.xyz/address/0xE7797e15DEb86931d7F7b940684Ed1edc5cC7513) | call | ๐ก caution | 50 | EIP-1967 upgradeable + EOA owner + paused |
| [Backdoor](https://atlantic.pharosscan.xyz/address/0x75fb8b091A7A88bAF14F23Eac2F33962A4Cdd35D) | call | ๐ด dangerous | 70 | SELFDESTRUCT + unguarded DELEGATECALL + paused |
Each verdict above is produced live, on-chain. The address/verdict map lives in
[`fixtures.json`](fixtures.json); `gallery.py` re-checks every exhibit and fails on any drift. The
Solidity-backed exhibits (CleanToken, ZeroSupplyToken, UpgradeableProxy, Backdoor) have **verified
source on Pharos Scan** โ open any address and check the Contract tab; sources are in
[`fixtures/`](fixtures/).
### Live upgrade attack โ Sentinel catches a rug as it happens
The gallery above is static. To prove Sentinel reads **live, mutable** state โ and to demonstrate
the exact threat it warns about โ a mutable EIP-1967 proxy was deployed pointing at benign logic,
then **upgraded on-chain to hostile logic in a single transaction**. Sentinel read the *same proxy
address* before and after:
| | Implementation | Verdict | What Sentinel sees |
|---|---|:--:|---|
| **Before** | benign logic | ๐ข safe (80) | upgradeable โ *owner can swap the logic after you interact* |
| **After** ([upgrade tx](https://atlantic.pharosscan.xyz/tx/0xc7e58da048465c0ecefbf5bc52f5a16ec0b828f529b2f379aa9fb562076cebc0)) | hostile logic | ๐ก caution (50) | + an EOA owner now holds privileged control + the contract is now PAUSED |
The proxy address never changed โ
[`0x22Aaโฆd27A`](https://atlantic.pharosscan.xyz/address/0x22Aa40e24a1186131C2Ea21db191095eD590d27A) โ
but its implementation, ownership, and pause state did. That is the upgrade-rug vector demonstrated
end to end: because Sentinel reads on-chain state at call time, its verdict reflects the swap the
moment it lands. The warning it prints *before* an attack is the risk that *becomes* the attack.
### Live pause flip โ Sentinel tracks operational state
A second live mutation, on a plain (non-proxy) contract. It carries a latent `SELFDESTRUCT`
(โ25 โ safety 75, still inside the safe band on its own); the operator then **pauses it in a single transaction**,
and Sentinel, reading state at call time, tips to caution:
| | `paused()` | Verdict | What Sentinel sees |
|---|:--:|:--:|---|
| **Before** | โ | ๐ข safe (75) | latent SELFDESTRUCT |
| **After** ([pause tx](https://atlantic.pharosscan.xyz/tx/0x97cad956c6242317be9ef8129a78e4335797014f72ff2e9499862c667bb88734)) | true | ๐ก caution (55) | + the contract is now PAUSED |
Same contract โ [`0xE84fโฆ410B`](https://atlantic.pharosscan.xyz/address/0xE84f51f6D4146bC3c676A190B8988EA9B2Db410B) โ
different live state, different verdict.
### The gate moves real value
`execution_plan` is not advisory theatre โ it decides whether value actually moves.
[`guarded_transfer.py`](guarded_transfer.py) asks Sentinel, then **sends a real PHRS transfer only
when the plan is approved**:
- โ
vetted counterparty โ `safe` โ approved โ [0.0005 PHRS sent](https://atlantic.pharosscan.xyz/tx/0x1d9979cb19c75f4385f984da5b0f68cb89d0993b9f511d0a9cc3ce198a12332a)
- โ the live `Backdoor` fixture โ `dangerous` โ blocked โ **no transaction is signed**
The Skill stays read-only; only the agent signs. One approval moved value on-chain; one block
stopped it before a transaction existed.
## Paid calls via x402
Pharos lists *"pay-per-query supplier / supply-chain risk assessment"* as a flagship x402
use case โ which is exactly what Sentinel is. So `risk_check` is also exposed behind an
**x402 paywall**: an unpaid request gets `402 Payment Required`; the agent pays a micro-transfer
on Atlantic; the retry returns the verdict plus the settlement `tx_hash`.
```bash
python sentinel_x402.py # read-only x402 gate on 127.0.0.1:4021
python x402_demo.py # 402 -> pay on Atlantic -> 200 verdict -> replay rejected
```
The gate verifies payment with the **same Foundry `cast` reads Sentinel uses for risk**, so the
server stays read-only and keyless (only the client sends value) and adds **no new dependencies**
beyond Foundry + the Python stdlib. Full design and the official `@x402` SDK path: [`X402.md`](X402.md).
## Verify it yourself
Nothing here is a recording. Sentinel only **reads** public Pharos Atlantic state, so you can
independently confirm both the verdicts and that it never writes.
**1. Offline tests โ no network, no Foundry:**
```bash
python -m unittest test_sentinel # 34 deterministic tests pin the verdict logic
```
**2. Live risk gallery โ read-only (needs Foundry `cast`):**
```bash
python gallery.py # re-checks the 6 deployed fixtures; fails on any drift
```
Expect a monotonic safe โ caution โ dangerous ladder, all six matching [`fixtures.json`](fixtures.json).
**3. Check any address yourself:**
```bash
python sentinel_cli.py 0x75fb8b091A7A88bAF14F23Eac2F33962A4Cdd35D call # the Backdoor fixture โ dangerous (exit 2)
```
**4. Reproduce Sentinel's reads by hand** โ proof it just reads public chain data via Foundry, nothing hidden:
```bash
RPC=https://atlantic.dplabs-internal.com
# the bytecode Sentinel scans (contains SELFDESTRUCT / unguarded DELEGATECALL):
cast code 0x75fb8b091A7A88bAF14F23Eac2F33962A4Cdd35D --rpc-url $RPC
# the pause flag it reads:
cast call 0x75fb8b091A7A88bAF14F23Eac2F33962A4Cdd35D "paused()(bool)" --rpc-url $RPC
# the EIP-1967 implementation slot of the UpgradeableProxy fixture:
cast storage 0xE7797e15DEb86931d7F7b940684Ed1edc5cC7513 \
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --rpc-url $RPC
```
These are exactly the reads documented in [`references/sentinel.md`](references/sentinel.md) โ Sentinel just
folds them into a single verdict. (All are `cast call` / `code` / `storage`; never `cast send`.)
## Security posture
The Skill is intentionally minimal and auditable. It executes only **read-only Foundry `cast`
commands** (`cast code` / `call` / `storage` / `balance` / `nonce` / `chain-id`, plus `cast rpc`
for tx/receipt lookups) against a single Pharos RPC endpoint โ the same execution model the rest
of the Skill Engine uses. It **holds no private key, never signs or sends a transaction, makes no
filesystem writes, and reads no secrets or environment** beyond the RPC URL.
## Files
| File | Role |
|------|------|
| `sentinel_skill.py` | MCP server โ tools `risk_check`, `execution_plan` |
| `pharos_atlantic.py` | Pharos Atlantic config + read-only Foundry `cast` read/introspection helpers |
| `sentinel_cli.py` | Thin CLI wrapper for SKILL.md / framework agents |
| `SKILL.md` | Skill definition for Claude Code / OpenClaw / Codex |
| `demo.py` | Live demo driver โ one subcommand per on-chain feature (deploy/upgrade/pause/transfer/x402) |
| `DEMO.md` | Live demo runbook โ commands, suggested patter, expected output |
| `demo_agent.py` | Demo agent driving the Skill over a real MCP connection against live Atlantic |
| `guarded_transfer.py` | Agent that sends a real PHRS transfer only when `execution_plan` approves |
| `feature_tour.py` | Guided walkthrough of every risk signal |
| `gallery.py` | Re-checks the live Atlantic risk-gallery fixtures and flags drift |
| `fixtures.json` | Deployed gallery addresses + expected verdicts |
| `sentinel_x402.py` | x402 paid-call gate โ `risk_check` behind HTTP 402 (read-only verify) |
| `x402_demo.py` | Drives the full x402 pay-per-query loop on live Atlantic |
| `X402.md` | x402 design: native verify-by-RPC + the official `@x402` SDK path |
| `references/sentinel.md` | Sentinel's engine reference file โ risk gate + Foundry (`cast`) read equivalents |
| `references/{query,transaction,contract,script-gen}.md` | Pharos Skill Engine command references (queries, transactions, contracts, script-gen) |
| `assets/networks.json` | Pharos network config (Atlantic testnet + mainnet) โ engine schema |
| `assets/tokens.json` | Known token registry (both networks) โ engine schema |
| `assets/{erc20,airdrop,templates}/` | Engine assets โ ERC-20 + airdrop Solidity, script-gen templates |
| `test_sentinel.py` | 34 offline, deterministic tests |
| `skill.json` | Sentinel MCP manifest |
## License
MIT-0 (MIT No Attribution) โ free to use, modify, and redistribute. See `LICENSE`.
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues