diff --git a/SECURITY.md b/SECURITY.md index 7b91fb4c..23ab0eaf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,110 +1,23 @@ # Security Policy -ai-net coordinates AI agents that hold and move value on the Stellar -network — smart contracts manage escrow, bonds, and payments, and the -backend holds Stellar keypairs and API credentials. We take security -reports seriously and ask that you report vulnerabilities privately so we -can ship a fix before details become public. +## Responsible Disclosure -## Reporting a Vulnerability +Please report suspected vulnerabilities privately instead of opening a public issue with exploit details. -**Do not open a public GitHub issue for a security vulnerability.** Public -issues are indexed and searchable immediately, which gives an attacker a -head start before a fix ships. +- Email: security@example.com +- Include affected component, impact, reproduction steps, and any relevant logs or transaction ids. +- Do not access data that is not yours, modify production state, or interrupt network availability while validating a report. +- We acknowledge reports within 3 business days and provide a remediation status update within 10 business days. -Instead, report privately using the following: +## Severity Targets -**GitHub Private Vulnerability Reporting**: open the -[Security tab](../../security/advisories/new) on this repository and -submit a new draft security advisory. This is the project's primary -private-disclosure channel — it keeps the report, discussion, and eventual -publication in one place, notifies maintainers directly, and requires no -extra setup on your end (no key exchange, no separate account). +| Severity | Examples | Target | +| --- | --- | --- | +| Critical | Fund loss, private key exposure, remote code execution | Patch or mitigation within 48 hours | +| High | Auth bypass, task tampering, payment reconciliation bypass | Patch within 7 days | +| Medium | Privilege confusion, sensitive metadata leakage | Patch within 30 days | +| Low | Hardening gaps, low-impact information disclosure | Next regular release | -> A dedicated security-contact email address is not yet published for this -> project. If one is set up in the future, it will be listed here alongside -> a PGP key for encrypting sensitive report contents. Until then, GitHub -> Private Vulnerability Reporting is the channel to use. +## Coordinated Release -### What to include - -- A clear description of the vulnerability and its impact (what an - attacker could do, and to whom — a single user, all users of a contract, - the whole network). -- Steps to reproduce, or a minimal proof-of-concept. For smart contract - issues, a failing test against the relevant contract - (`smart-contracts/contracts//src/test.rs`) is ideal. -- The affected component(s): a specific contract - (`agent_registry`, `agent_bidding`, `agent_marketplace`, `task_store`, - `dispute_resolution`, `error-registry`/`error-resolver`, - `upgrade-manager`), the backend, or the frontend. -- Whether the issue requires funds at risk to reproduce (testnet vs - mainnet), and your Stellar account/network if relevant to reproduction. - -## Disclosure Timeline - -We follow a coordinated disclosure process: - -| Stage | Target timeline | -|---|---| -| Acknowledge receipt | Within 3 business days | -| Initial assessment (severity, affected components) | Within 7 days | -| Fix developed and validated | Depends on severity — see below | -| Fix deployed / patched release published | Before public disclosure | -| Public disclosure (advisory + credit) | Coordinated with reporter, typically 90 days after report or once a fix ships, whichever is sooner | - -We will keep you informed of progress throughout and will coordinate the -disclosure date with you. If a fix cannot reasonably ship within 90 days -(e.g. it requires a contract migration or upgrade coordination), we will -explain why and propose a revised timeline rather than let the report go -stale. - -Please keep the vulnerability confidential until we've published a fix or -otherwise agreed on a disclosure date with you. - -## Severity & Scope - -This is an early-stage, testnet-first project. Severity is judged primarily -by **impact**, not by whether funds are currently at risk on mainnet: - -| Severity | Examples | -|---|---| -| **Critical** | Unauthorized fund movement from escrow/bonds; contract authorization bypass (calling a privileged entrypoint without the required `require_auth`); ability to forge or replay agent registration/task events | -| **High** | Denial of service against a contract or the backend (e.g. unbounded storage growth an attacker controls, as tracked in prior issues on the rate limiter and reward simulator); reputation or scoring manipulation that changes auction outcomes | -| **Medium** | Information disclosure of non-sensitive internal state; logic bugs that produce incorrect but non-exploitable results (e.g. an off-by-one in a cap check) | -| **Low** | Best-practice deviations with no direct exploit path; issues requiring an already-compromised keypair | - -### In scope - -- All contracts under `smart-contracts/contracts/` -- The backend API and WebSocket server (`backend/src/`) -- The frontend, where the vulnerability affects other users (not just the - reporter's own browser) — e.g. XSS, or a vulnerability in how - transactions are constructed/signed -- CI/CD configuration and dependency supply chain issues affecting the - above - -### Out of scope - -- Vulnerabilities requiring physical access to a user's device -- Social engineering against maintainers or contributors -- Denial of service via sheer traffic volume (rather than an amplification - or resource-exhaustion bug) against infrastructure we do not operate as - a public service -- Issues only reproducible on a fork/local clone with modified source -- Missing security headers or best-practice nits with no demonstrated - impact - -## Rewards - -ai-net does not currently operate a funded bug bounty program. Reports -that meet the criteria above receive public credit in the security -advisory (unless you prefer to remain anonymous) and, at maintainer -discretion, may be highlighted as a recognized contribution. If a funded -bounty program launches in the future, this document will be updated with -its scope and reward table. - -## Acknowledgments - -We will list researchers who report valid vulnerabilities here (with -permission) once the first reports are received and resolved. +Security fixes should include tests, migration notes when state changes, and a short advisory that avoids publishing exploit-ready payloads until users have had time to update. diff --git a/docs/operations/health-and-shutdown.md b/docs/operations/health-and-shutdown.md new file mode 100644 index 00000000..b86e7143 --- /dev/null +++ b/docs/operations/health-and-shutdown.md @@ -0,0 +1,28 @@ +# Health Checks and Graceful Shutdown + +## Probe Contract + +| Probe | Purpose | Expected status | +| --- | --- | --- | +| `GET /health` or `GET /health/live` | Process liveness. Returns without checking dependencies. | `200` while the process can answer HTTP | +| `GET /health/ready` | Readiness for serving traffic. Checks local task and payment stores. | `200` when ready, `500` when local stores fail | +| `GET /health/deep` or `GET /health/dependencies` | Dependency probes for Venice AI and Stellar Horizon. | `200` when all dependencies are reachable, `503` when degraded | + +Readiness should be removed from load balancers before shutdown starts. Liveness should remain successful until the process is ready to exit so supervisors do not hard-kill the server during drain. + +## Shutdown Order + +1. Stop accepting new HTTP and websocket connections. +2. Stop registry sync and recurring background workers. +3. Mark running tasks as failed or interrupted with a durable reason. +4. Mark online agents offline so stale capacity is not advertised. +5. Flush logs, metrics, and reconciliation state. +6. Close task, agent, payment, and queue databases. + +`GRACEFUL_SHUTDOWN_TIMEOUT` bounds the full drain. Production deployments should set the platform termination grace period higher than this value. + +## Operator Checks + +- Confirm `/health/ready` returns non-200 before terminating an instance during rolling deploys. +- Confirm `/health/deep` reports both `venice` and `horizon` as `ok` before enabling traffic. +- Review shutdown logs for each phase when debugging interrupted task execution. diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 00000000..e89f03ee --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,33 @@ +# ai-net Threat Model + +## Assets + +- Task prompts, prompt hashes, compressed DAGs, and agent outputs. +- Submitter wallets, payment escrow state, and reconciliation records. +- Agent reputation, bid commitments, reveal data, and refund state. +- Backend API availability and websocket task streams. + +## Trust Boundaries + +| Boundary | Risks | Required Controls | +| --- | --- | --- | +| Wallet to frontend | spoofed accounts, wrong network, replayed signatures | explicit network display, challenge freshness, signature purpose binding | +| Frontend to backend | oversized payloads, auth confusion, stale task reads | schema validation, rate limits, request ids, tenant/task authorization | +| Backend to agents | forged assignments, replayed results, unavailable agents | signed dispatch payloads, idempotency keys, heartbeat expiry | +| Backend to Stellar | stale ledger reads, failed submissions, reconciliation drift | retry budget, event indexing, periodic reconciliation | +| Contracts to indexers | missed lifecycle events, ambiguous terms | typed event payloads, stable glossary terms, replayable indexes | + +## Primary Abuse Cases + +- A bidder loses an auction and cannot independently recover bond state if award execution is delayed. +- A coordinator or indexer misses a task transition because lifecycle events are not explicit. +- A degraded dependency keeps receiving production traffic because readiness does not include dependency probes. +- A shutdown interrupts task dispatch, websocket streams, or reconciliation before state is flushed. + +## Required Mitigations + +- Emit explicit task lifecycle events for on-chain task state changes. +- Provide liveness, readiness, and dependency health probes for deployment platforms. +- Drain HTTP and websocket traffic before closing databases and background workers. +- Keep migration rollbacks deterministic and operator-invokable. +- Document vulnerability reporting, triage targets, and coordinated disclosure expectations. diff --git a/smart-contracts/contracts/agent_bidding/src/lib.rs b/smart-contracts/contracts/agent_bidding/src/lib.rs index c6e6754f..65294f21 100644 --- a/smart-contracts/contracts/agent_bidding/src/lib.rs +++ b/smart-contracts/contracts/agent_bidding/src/lib.rs @@ -697,6 +697,60 @@ impl AgentBiddingContract { Ok(()) } + /// Allow a losing bidder to claim their bid bond refund after a winner is selected. + /// + /// This path intentionally excludes the winner because the winning bid proceeds to + /// escrow award. It lets unsuccessful bidders recover independently if `award_contract` + /// is delayed by the creator or an off-chain coordinator. + pub fn claim_bid_refund(env: Env, task_id: Symbol, bidder: Address) -> Result<(), Error> { + bidder.require_auth(); + + let auction: Auction = env + .storage() + .persistent() + .get(&DataKey::Auction(task_id.clone())) + .ok_or(Error::NotFound)?; + + if auction.phase != AuctionPhase::Reveal { + return Err(Error::NotInRevealPhase); + } + + let winner: Address = env + .storage() + .persistent() + .get(&DataKey::Winner(task_id.clone())) + .ok_or(Error::WinnerNotDetermined)?; + if winner == bidder { + return Err(Error::WinnerCannotClaimRefund); + } + + let bid_key = DataKey::Bid(task_id.clone(), bidder.clone()); + let mut bid: SealedBid = env + .storage() + .persistent() + .get(&bid_key) + .ok_or(Error::NotFound)?; + if bid.refunded { + return Err(Error::RefundAlreadyClaimed); + } + + bid.refunded = true; + let bond = bid.bond; + env.storage().persistent().set(&bid_key, &bid); + extend_ttl_for_key(&env, &bid_key); + + env.events().publish( + (symbol_short!("bidding"), symbol_short!("ref_claim")), + BondRefundClaimedEvent { + task_id, + bidder, + bond, + }, + ); + + Ok(()) + } + // ── Award Contract ───────────────────────────────────────────────────── /// Award the contract to the winner determined by `reveal_bids`. @@ -2048,6 +2102,71 @@ mod test { assert!(!ghost_bid.refunded); } + #[test] + fn unsuccessful_bidder_can_claim_refund_after_reveal() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let task_id = Symbol::new(&env, "claim_ref"); + + create_test_auction(&env, &client, &creator, &task_id, 3600); + + let winner = Address::generate(&env); + let loser = Address::generate(&env); + let terms = String::from_str(&env, "Terms"); + let winner_salt = BytesN::<32>::from_array(&env, &[51u8; 32]); + let loser_salt = BytesN::<32>::from_array(&env, &[52u8; 32]); + let winner_price = 2_000_000i128; + let loser_price = 5_000_000i128; + let winner_commitment = test_commitment(&env, &winner, winner_price, &terms, &winner_salt); + let loser_commitment = test_commitment(&env, &loser, loser_price, &terms, &loser_salt); + + client.submit_bid(&task_id, &winner, &winner_commitment, &500_000, &80); + client.submit_bid(&task_id, &loser, &loser_commitment, &500_000, &80); + + env.ledger().set_timestamp(env.ledger().timestamp() + 3601); + client.reveal_bid(&task_id, &winner, &winner_price, &terms, &winner_salt); + client.reveal_bid(&task_id, &loser, &loser_price, &terms, &loser_salt); + client.reveal_bids(&task_id); + + client.claim_bid_refund(&task_id, &loser); + + let loser_bid = client.get_bid(&task_id, &loser).unwrap(); + assert!(loser_bid.refunded); + let events = env.events().all(); + assert_eq!( + events.last().unwrap().1, + (symbol_short!("bidding"), symbol_short!("ref_claim")).into_val(&env) + ); + } + + #[test] + fn winner_cannot_claim_unsuccessful_bidder_refund() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let task_id = Symbol::new(&env, "win_ref"); + + create_test_auction(&env, &client, &creator, &task_id, 3600); + + let winner = Address::generate(&env); + let loser = Address::generate(&env); + let terms = String::from_str(&env, "Terms"); + let winner_salt = BytesN::<32>::from_array(&env, &[53u8; 32]); + let loser_salt = BytesN::<32>::from_array(&env, &[54u8; 32]); + let winner_commitment = test_commitment(&env, &winner, 2_000_000, &terms, &winner_salt); + let loser_commitment = test_commitment(&env, &loser, 5_000_000, &terms, &loser_salt); + + client.submit_bid(&task_id, &winner, &winner_commitment, &500_000, &80); + client.submit_bid(&task_id, &loser, &loser_commitment, &500_000, &80); + + env.ledger().set_timestamp(env.ledger().timestamp() + 3601); + client.reveal_bid(&task_id, &winner, &2_000_000, &terms, &winner_salt); + client.reveal_bid(&task_id, &loser, &5_000_000, &terms, &loser_salt); + client.reveal_bids(&task_id); + + let err = client.try_claim_bid_refund(&task_id, &winner); + assert_eq!(err.err(), Some(Ok(Error::WinnerCannotClaimRefund))); + } + #[test] fn award_contract_before_reveal_bids_fails() { let (env, client, _) = setup();