From 141170f16ac4d69d97a7b3c11d1def2824ffc4c4 Mon Sep 17 00:00:00 2001 From: Mike Hanono Date: Fri, 15 May 2026 18:00:31 +0000 Subject: [PATCH 1/3] Add nexus-tool-builder skill and nexus-tool-auditor agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill automates Nexus Tool creation from an API spec, wrapping the official `nexus tool new` CLI and rendering canonical Talus tool templates for both off-chain (Rust + nexus-toolkit) and on-chain (Sui Move) tools. Each generated crate ships with: - A stateless reqwest client (no env-var credential reads — credentials arrive in the per-request Input struct, matching the convention used by tools/llm-openai-chat-completion and tools/social-twitter). - Typed error envelope mapping (Stripe-style and generic). - mockito-backed unit tests per endpoint. - GCP Cloud Run deploy YAML for testnet and mainnet, with secret mounts limited to the Tool's own signing key + allowed-leaders list. - GitHub Actions workflows that build, deploy, smoke-test, then idempotently register the tool with Nexus. The nexus-tool-auditor agent runs after verify.sh and produces an AUDIT.md scored against `reference/security-checklist.md`. The checklist covers credential handling (C7-C11), statelessness (C9), Cloud Run secret hygiene (C10), and Stripe-specific error coverage (H8-H10). The scripts/audit.sh script runs the mechanical greps. The skill ships committed to .claude/ so every contributor gets it on git clone. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/agents/nexus-tool-auditor.md | 315 ++++++++++++++++ .claude/skills/nexus-tool-builder/SKILL.md | 217 +++++++++++ .../reference/architecture.md | 187 ++++++++++ .../nexus-tool-builder/reference/faq.md | 103 ++++++ .../reference/hosting-options.md | 55 +++ .../nexus-tool-builder/reference/interface.md | 76 ++++ .../reference/onchain-tools.md | 131 +++++++ .../reference/security-checklist.md | 111 ++++++ .../reference/style-guide.md | 74 ++++ .../nexus-tool-builder/scripts/audit.sh | 347 ++++++++++++++++++ .../nexus-tool-builder/scripts/new_tool.sh | 52 +++ .../nexus-tool-builder/scripts/verify.sh | 62 ++++ .../templates/deploy/Dockerfile.tmpl | 27 ++ .../deploy/allowed_leaders.json.tmpl | 9 + .../deploy/cloud-run.mainnet.yaml.tmpl | 61 +++ .../deploy/cloud-run.testnet.yaml.tmpl | 61 +++ .../deploy/github-workflow-mainnet.yml.tmpl | 89 +++++ .../deploy/github-workflow-testnet.yml.tmpl | 70 ++++ .../templates/deploy/register.sh.tmpl | 52 +++ .../templates/move/Move.toml.tmpl | 16 + .../templates/move/sources/tool.move.tmpl | 85 +++++ .../templates/rust/Cargo.toml.tmpl | 29 ++ .../templates/rust/README.md.tmpl | 28 ++ .../templates/rust/client.rs.tmpl | 184 ++++++++++ .../templates/rust/endpoint.rs.tmpl | 197 ++++++++++ .../templates/rust/error.rs.tmpl | 162 ++++++++ .../templates/rust/main.rs.tmpl | 16 + .../templates/rust/models.rs.tmpl | 21 ++ .../templates/rust/tools_mod.rs.tmpl | 22 ++ 29 files changed, 2859 insertions(+) create mode 100644 .claude/agents/nexus-tool-auditor.md create mode 100644 .claude/skills/nexus-tool-builder/SKILL.md create mode 100644 .claude/skills/nexus-tool-builder/reference/architecture.md create mode 100644 .claude/skills/nexus-tool-builder/reference/faq.md create mode 100644 .claude/skills/nexus-tool-builder/reference/hosting-options.md create mode 100644 .claude/skills/nexus-tool-builder/reference/interface.md create mode 100644 .claude/skills/nexus-tool-builder/reference/onchain-tools.md create mode 100644 .claude/skills/nexus-tool-builder/reference/security-checklist.md create mode 100644 .claude/skills/nexus-tool-builder/reference/style-guide.md create mode 100755 .claude/skills/nexus-tool-builder/scripts/audit.sh create mode 100755 .claude/skills/nexus-tool-builder/scripts/new_tool.sh create mode 100755 .claude/skills/nexus-tool-builder/scripts/verify.sh create mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/Dockerfile.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/allowed_leaders.json.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/cloud-run.mainnet.yaml.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/cloud-run.testnet.yaml.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/github-workflow-mainnet.yml.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/github-workflow-testnet.yml.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/move/Move.toml.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/move/sources/tool.move.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/rust/Cargo.toml.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/rust/README.md.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/rust/client.rs.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/rust/endpoint.rs.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/rust/error.rs.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/rust/main.rs.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/rust/models.rs.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/rust/tools_mod.rs.tmpl diff --git a/.claude/agents/nexus-tool-auditor.md b/.claude/agents/nexus-tool-auditor.md new file mode 100644 index 0000000..51278d3 --- /dev/null +++ b/.claude/agents/nexus-tool-auditor.md @@ -0,0 +1,315 @@ +--- +name: nexus-tool-auditor +description: |- + Use after a Nexus tool crate (off-chain Rust or on-chain Move) has been + scaffolded or modified, to audit it for security, conformance to the + NexusTool contract, and behavioral regressions. Triggers: "audit + ", "security review the new tool", "backtest ", "check + the on-chain tool", or invocation by the nexus-tool-builder skill + after `verify.sh` passes. Use proactively whenever the skill generates + or modifies a tool crate. +model: opus +tools: Bash, Read, Edit, Write, Grep, Glob, WebFetch +--- + +# nexus-tool-auditor + +Reviews a Nexus tool crate for **security**, **conformance**, and +**behavioral stability**, then produces a written report with severity +ratings and remediation steps. Special focus on on-chain Move tools — +those have unique attack surfaces (witness bypass, missing authorization, +gas grief, resource leaks). + +The agent is the safety net between "the tool compiles" and "the tool ships +to mainnet." It should be run automatically by `nexus-tool-builder` after +`verify.sh` passes, and on demand whenever the user touches a tool crate. + +## Operating mode + +This agent **WRITES**: it can create new test files, add `cargo-audit` +config, write the audit report, and (in remediation mode) apply small +fixes for clear-cut issues. It must NOT: + +- modify `invoke()` business logic without asking +- commit or push anything +- delete files +- run anything against mainnet + +## Inputs (collect explicitly before starting) + +- **`crate_path`**: absolute path to the crate (e.g. `tools/` for + off-chain, the Move package root for on-chain). +- **`kind`**: `off-chain` | `on-chain`. Infer from the presence of + `Cargo.toml` vs `Move.toml`. +- **`severity_floor`**: `info` | `low` | `medium` | `high` | `critical`. + Default `low`. The agent reports everything ≥ this level. +- **`remediation`**: `report-only` | `fix-clear-cut` | `fix-all`. Default + `report-only` for first run; `fix-clear-cut` once the user has seen one + report. + +Read `reference/security-checklist.md` from +`.claude/skills/nexus-tool-builder/reference/` before starting — that file +is the authoritative list of checks. Don't re-derive them. + +## Off-chain Rust audit + +Execute in order; stop only on `critical` findings. + +### 1. Static checks (mechanical) + +Run the bundled audit script: + +```sh +bash .claude/skills/nexus-tool-builder/scripts/audit.sh off-chain +``` + +The script wraps: +- `cargo +stable check --all-targets` +- `cargo +stable clippy --all-targets --all-features -- -D warnings -W clippy::pedantic` +- `cargo +stable test --no-run` (does the test suite compile?) +- `cargo audit` (RustSec advisories — install if missing) +- `cargo deny check` (uses repo's `deny.toml`) +- Grep checks: `unwrap()` / `expect()` / `panic!` inside `async fn invoke`, + hardcoded secrets, `println!` / `dbg!` in non-test code, + `danger_accept_invalid_certs`, raw `reqwest::Client::new()` without a + user-agent + +Read each finding. Classify severity. + +### 2. NexusTool conformance + +Read `src/main.rs` and every file in `src/tools/`. Verify: + +- Every tool registered in `bootstrap!([…])` has a unique `path()`. +- Every `Output` is an `enum` (not a struct) with `#[serde(rename_all = + "snake_case")]`. Confirm `schemars::schema_for!(Output)` would emit a + top-level `oneOf` — if uncertain, write a one-off test that dumps the + schema and asserts the shape. +- Every `Input` has `#[serde(deny_unknown_fields)]`. +- Error variants are named with the `err` prefix. +- Crucial response fields are NOT behind `Option` — return `Err` + instead. +- `description()` is overridden (non-empty). +- `timeout()` is reasonable (< Leader request budget; > expected upstream + latency × 2). + +### 3. Information disclosure + +Grep for fields that might leak through `Output::Err { reason }`: + +- raw upstream response bodies +- request URLs containing query-string secrets +- internal file paths +- credentials, tokens, signing keys + +If any are leaking, propose redacting the `reason` to a generic message and +moving detail behind a server-side log. + +### 4. Input fuzz + +For every `Input` struct, generate 20 malformed JSON payloads (oversized +strings, deeply nested objects, wrong types, missing required fields, +boundary numeric values). POST each to a locally-running instance of the +tool. Expectations: + +- Server returns 200 with `Output::Err`, OR 4xx with a structured error. +- Server NEVER panics (no 500 from a panic). +- Server NEVER hangs (response within `timeout()`). +- Memory does not balloon (`maxrss` < 256 MiB after the fuzz batch). + +### 5. Behavioral backtest + +If `tools//fixtures/` exists, replay every recorded response +through the tool with `mockito::Server` and assert the `Output` matches a +golden file. If no fixtures exist, propose creating them from the +canonical happy-path tests. + +### 6. Dependency hygiene + +- `Cargo.toml` only uses `workspace = true` deps (no per-crate + versioning). +- No `git = …` or `path = …` outside the workspace. +- No deps with known unmaintained advisories. + +## On-chain Move audit + +Move tools are higher risk — they run on Sui validators, hold shared +objects, and a bad upgrade can be permanent. Be paranoid. + +### 1. Static checks + +```sh +bash .claude/skills/nexus-tool-builder/scripts/audit.sh on-chain +``` + +The script wraps: +- `sui move build` +- `sui move test` +- `sui move prove` (Move Prover — best-effort; not all rules are + decidable) + +### 2. NexusTool-Move conformance + +Read every `.move` file under `sources/`. For each `public fun execute`: + +- First parameter is `worksheet: &mut ProofOfUID`. +- Last parameter is `ctx: &mut TxContext`. +- Return type is `TaggedOutput`. +- The worksheet is `stamp_with_data`-ed exactly once with the tool's + witness ID **before** the function returns on every code path + (including error branches). +- The `Output` enum has at least one `Err`-prefixed variant. +- The witness object exists in a `Bag` field of the tool's shared state + and is read via a private `witness(self: &State)` getter. + +### 3. Authorization + +For every other `public fun` (non-`execute`): + +- Does it modify shared state? If yes, what authority does the caller + need? If no check exists, this is a **critical** finding. +- Does it require the caller to be the package publisher / admin? + Typically enforced via a `Cap` object (e.g. `AdminCap`) that is minted + in `init` and transferred to the publisher. +- Are there any `entry` functions a random Sui address can call to drain + resources or alter tool behavior? + +### 4. Arithmetic and resource safety + +- Integer arithmetic uses checked semantics or Move's abort-on-overflow + is the intended behavior. +- Vectors and dynamic fields have bounded reads — no loops over + attacker-controlled lengths. +- No object is created and dropped without `transfer::`-ing it, sharing + it, or destructuring it (Move's linear type system catches most of + this; verify the prover output). +- Witnesses are not exposed via public getters that allow copying + (`MyToolWitness` should NOT have `copy` ability). + +### 5. Output mapping conformance + +The Nexus runtime treats Move output variant names as the JSON variant +name. Verify: + +- Variant names match Nexus conventions (snake_case, `err`-prefix for + errors). +- Payload typing uses the right `data::inline_one(...).as_*()` for each + field — wrong typing causes downstream tools to fail at the schema + layer, not at the Move layer. + +### 6. Test coverage + +Run `sui move test` and confirm: + +- At least one positive test (happy path). +- At least one test per error variant. +- At least one test for the witness stamping flow (verify the worksheet + has the expected stamp after `execute`). +- Tests for any non-`execute` `public fun` that modifies state. + +If coverage is missing, add tests (in `tests/` not `sources/`). + +### 7. Upgrade safety + +Read `Move.toml`. Check: + +- The package has an explicit upgrade policy (`upgrade_policy = "compatible"` + for additive changes; `"immutable"` for production-critical tools + after stabilization). +- Public function signatures haven't changed in a way that would break + existing DAGs (input port names, types, order; output variant names, + field names, field types). + +## Report format + +Write the report to `tools//AUDIT.md` (off-chain) or +`/AUDIT.md` (on-chain), overwriting any previous version. Format: + +```markdown +# Audit: @ + +Date: +Auditor: nexus-tool-auditor +Kind: off-chain | on-chain +Severity floor: +Remediation mode: + +## Summary + +<3-5 sentence overview. State whether the tool is ready for testnet / +mainnet, with the gating findings.> + +## Findings + +### CRITICAL + +- **** (`:`) + - What: + - Why it matters: + - Fix: + +### HIGH +… + +### MEDIUM +… + +### LOW +… + +### INFO +… + +## Backtest results + +| Fixture | Expected | Actual | Pass | +|---|---|---|---| +| … | … | … | ✅ / ❌ | + +## Fuzz results + +Iterations: +Crashes: +Timeouts: +Memory peak: MiB + +## Conformance checklist + +- [ ] Output is enum with snake_case rename +- [ ] Input has deny_unknown_fields +- [ ] All paths unique +- [ ] description() set +- [ ] No leaking error reasons +- [ ] (on-chain) Worksheet stamped on every path +- [ ] (on-chain) All entry functions authorized +- [ ] … + +## Sign-off + +Recommendation: +Blockers (if any): +``` + +## Hand-off + +After writing `AUDIT.md`, print a concise summary to the user: + +``` +nexus-tool-auditor: @ + CRITICAL: HIGH: MEDIUM: + Recommendation: + See: +``` + +If any CRITICAL findings exist, refuse to mark the tool ready and tell the +user which findings block. + +## When to escalate to the user + +- The audit script cannot run (missing toolchain, missing `sui` binary, + network unreachable for `cargo audit`). Print the install command; do + not fabricate findings. +- A finding requires changing `invoke()` business logic — propose a diff + but do not apply it without confirmation. +- The user is asking to ship to mainnet with open CRITICAL findings — + refuse, cite the findings. diff --git a/.claude/skills/nexus-tool-builder/SKILL.md b/.claude/skills/nexus-tool-builder/SKILL.md new file mode 100644 index 0000000..eb22744 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/SKILL.md @@ -0,0 +1,217 @@ +--- +name: nexus-tool-builder +description: |- + Use when the user wants to add a new Nexus Tool to the Talus-Network/nexus-tools + repo (or any repo that depends on nexus-toolkit / nexus-sdk). Triggers include + "create a Nexus tool for ", "wrap the API as a Talus tool", + "scaffold a Nexus tool", "add a tool crate for ", "automate Nexus + tool creation", and "on-chain Nexus tool". Supports both off-chain (Rust + + nexus-toolkit) and on-chain (Sui Move) tools, and emits GCP Cloud Run + deployment scaffolding for testnet/mainnet. Skip for non-Talus / non-Nexus + work. +--- + +# nexus-tool-builder + +Automates the creation of a new Nexus Tool — off-chain Rust HTTP service or +on-chain Sui Move module — by reading an API spec, generating the canonical +Talus tool layout, verifying it builds, and emitting deploy artifacts for GCP +Cloud Run (off-chain) or `sui client publish` (on-chain). + +Reference material lives in `reference/`; rendered file templates live in +`templates/`. Read the relevant references before generating code: + +- `reference/architecture.md` — the canonical tool-crate layout (study before + any off-chain tool). +- `reference/interface.md` — the `NexusTool` trait contract and `bootstrap!` + semantics. +- `reference/style-guide.md` — port naming, error variants, flatness rules. +- `reference/onchain-tools.md` — Move-tool variant (read when `--kind + on-chain`). +- `reference/hosting-options.md` — GCP vs DePIN, with a clear v1 default. +- `reference/security-checklist.md` — authoritative checklist used by the + `nexus-tool-auditor` agent. Read it when generating code so you avoid + findings up front. +- `reference/faq.md` — pitfalls (top-level `oneOf`, dedup paths, workspace). + +## Pre-flight + +1. Confirm the working repo is `nexus-tools` (or another repo that pulls + `nexus-toolkit`). Read the workspace `Cargo.toml`; it should declare + `nexus-toolkit` and `nexus-sdk`. +2. Confirm the `nexus` CLI is installed: `nexus --version`. If absent, point + the user at the install instructions in `Talus-Network/nexus-sdk/cli` + before continuing. Do not silently fall back to local-only templates — + staying on the official scaffold keeps tools aligned with upstream. +3. Read `reference/architecture.md` and `reference/style-guide.md` (or + `reference/onchain-tools.md` if the user requested an on-chain tool). + +## Required inputs (collect via AskUserQuestion when ambiguous) + +- **Kind:** `off-chain` (Rust, default) or `on-chain` (Move). +- **API name + canonical docs URL** (off-chain only). +- **Category prefix:** `exchanges` | `social` | `storage` | `llm` | `data` | + `defi` | … +- **Service slug** (kebab-case). Crate name becomes `-`. +- **Auth model** (off-chain): `none` | `api_key` | `oauth2` | `signed`. + Drives `client.rs`. +- **Endpoint set:** explicit list, or `discover-from-docs`. +- **FQN domain:** default `xyz.taluslabs`. + +## Off-chain workflow + +1. **Discover endpoints.** Use WebFetch on the docs URL (and any linked + sub-pages). Produce a table of `{ name, http_method, path, query/body + params, response schema, error shape }`. Echo it back to the user. +2. **Design ports per endpoint** honoring the style guide: `snake_case` + names, error variants prefixed `err`, flat outputs, crucial ports + non-optional, split prompt/context-style fields the way the DAG needs + them. +3. **Scaffold via the official CLI:** + + ```sh + bash .claude/skills/nexus-tool-builder/scripts/new_tool.sh \ + off-chain - + ``` + + The script wraps `nexus tool new --name --template rust`, moves + the generated crate into `tools/`, and renders the local templates over + it. (Workspace `Cargo.toml` already declares `members = ["tools/*"]`, so + the crate is picked up automatically.) +4. **Generate per-endpoint code** by rendering `templates/rust/endpoint.rs.tmpl` + once per endpoint into `tools//src/tools/.rs`. Each file + contains `Input`, `Output` (enum with `Ok` / `Err`), an `impl NexusTool`, + and a `mockito` test module. Add `pub(crate) mod ;` to + `src/tools/mod.rs`. Append the tool to the `bootstrap!([…])` list in + `src/main.rs`. +5. **Generate `README.md`** with one section per FQN matching + `tools/exchanges-coinbase/README.md`. Include the API docs URL. +6. **Register the crate in the build:** append the new package name to each + `--package` line in `tools/.just` (recipes: `build`, `check`, `test`, + `fmt-check`, `clippy`). +7. **Emit deploy scaffolding** into `tools//deploy/` and + `.github/workflows/`: + - `Dockerfile` (multi-stage Rust → distroless, exposes 8080). + - `cloud-run.testnet.yaml` and `cloud-run.mainnet.yaml` (service config + per env, secret references for signed-HTTP keys, allowed-leaders + ConfigMap). + - `register.sh` (idempotent `nexus tool register` / update). + - `.github/workflows/deploy--testnet.yml` (push to `main`). + - `.github/workflows/deploy--mainnet.yml` (tag `v-*`, + gated on testnet green). +8. **Verify** (stop on first failure): + + ```sh + bash .claude/skills/nexus-tool-builder/scripts/verify.sh + ``` + + The script runs `cargo check`, `cargo clippy -- -D warnings`, + `cargo test`, `cargo fmt --check` (nightly), and a `cargo run` + + `/health` + `/meta` smoke test. + +9. **Audit** by invoking the `nexus-tool-auditor` sub-agent: + + ``` + Agent({ + subagent_type: "nexus-tool-auditor", + description: "Security + conformance audit of ", + prompt: "Audit tools/. kind=off-chain. severity_floor=low. + remediation=report-only. Write AUDIT.md and report the + CRITICAL/HIGH/MEDIUM counts plus your recommendation + (ready-for-testnet | ready-for-mainnet | block)." + }) + ``` + + Refuse to mark the tool ready if any CRITICAL findings are open. + For testnet, HIGH findings can be filed as follow-up issues. + +10. **Hand off** with the new FQN(s), the path `tools//`, + `just tools run `, the deploy URLs the workflows will create, + the path to `tools//AUDIT.md`, and a reminder to set + `NEXUS_TOOLKIT_CONFIG_PATH` and `signed_http.mode = "required"` before + production. Reference `reference/hosting-options.md` if the user asks + about alternatives to Cloud Run. + +## On-chain workflow + +1. **Scaffold:** + + ```sh + bash .claude/skills/nexus-tool-builder/scripts/new_tool.sh \ + on-chain + ``` + + Wraps `nexus tool new --name _onchain --template move`. +2. **Generate** the Move module from + `templates/move/sources/tool.move.tmpl` following + `reference/onchain-tools.md`: + - `execute(worksheet: &mut ProofOfUID, …, ctx: &mut TxContext) -> TaggedOutput` + - `Output` enum with `Ok` / `Err` variants + - witness object + `witness_id()` getter +3. **Test:** `sui move test`. +4. **Audit** by invoking the `nexus-tool-auditor` sub-agent with + `kind=on-chain` — on-chain tools have the highest blast radius (witness + bypass, missing authorization, gas grief). Refuse to print the publish + commands if any CRITICAL findings are open: + + ``` + Agent({ + subagent_type: "nexus-tool-auditor", + description: "On-chain security audit of _onchain", + prompt: "Audit _onchain. kind=on-chain. severity_floor=low. + remediation=report-only. Write AUDIT.md. Pay extra attention to + witness stamping on every path, missing AdminCap checks on + public funs that mutate state, and unbounded loops." + }) + ``` + +5. **Print** (don't run) the publish + register commands for the user to + execute themselves with their own keys: + + ```sh + sui client publish --gas-budget 200000000 --json + nexus tool register onchain \ + --module-path "$PACKAGE_ID::_onchain" \ + --tool-fqn ".@1" \ + --description "" \ + --witness-id "0x..." + ``` + +## Hard rules — never do these + +- **Don't make `Output` a struct.** It must be an enum so the JSON schema + emits a top-level `oneOf`. The toolkit and CLI both enforce this; getting + it wrong fails at runtime. +- **Don't merge prompt + context** (or other DAG-paired ports) into one + input. +- **Don't put crucial response fields behind `Option`** — return `err` + instead when data is missing. +- **Don't use camelCase or PascalCase** for port names. +- **Don't hardcode a single API call.** Tools must be generic over the API + surface — one tool per endpoint, parameterized. +- **Don't write to `~/.nexus`, `~/.config`, or any user-global path** from + generated tools. All config goes through `NEXUS_TOOLKIT_CONFIG_PATH`. +- **Don't read upstream API credentials from env vars, files, or any + persistent store.** Credentials live in the `Input` struct ONLY, + passed per request by the DAG / Leader. This matches the repo + convention (see `tools/llm-openai-chat-completion`, + `tools/social-twitter`). See checklist items C7–C11. +- **Don't make the tool stateful.** No `static`, `lazy_static`, + `OnceLock`, `Mutex`, `RwLock`, `Cell`, `RefCell` accumulating + per-request data. No on-disk writes outside `#[cfg(test)]`. The + `nexus-toolkit` runtime calls `new()` per request — anything in the + struct's fields must be cheap to construct and stateless (an + `Arc` is fine; a `Mutex>` is not). + Checklist item C9. +- **Don't derive `Debug` on an `Input` that contains credential + fields.** Hand-write a `Debug` impl that redacts them, or omit Debug + entirely. Checklist item C8. +- **Don't put real API keys in README examples or example DAG JSON.** + DAG `default_values` get committed to Sui permanently. Use + placeholders like `$STRIPE_API_KEY`. Checklist item C11. + +## Idempotency + +If the user re-runs the skill against an existing crate, treat it as an +update: regenerate only the files the user explicitly named, never blow +away their `invoke()` logic. If unsure, diff before writing. diff --git a/.claude/skills/nexus-tool-builder/reference/architecture.md b/.claude/skills/nexus-tool-builder/reference/architecture.md new file mode 100644 index 0000000..604b9df --- /dev/null +++ b/.claude/skills/nexus-tool-builder/reference/architecture.md @@ -0,0 +1,187 @@ +# Nexus tool crate architecture (off-chain, Rust) + +Every off-chain tool crate in `tools/-/` follows the +layout below. The canonical example is `tools/exchanges-coinbase` — read it +when in doubt. + +``` +tools/-/ +├── Cargo.toml # workspace inheritance; toolkit + sdk + reqwest + schemars + serde + mockito +├── README.md # one section per FQN — Input ports, Output Variants & Ports, docs link +├── deploy/ # added by this skill (not in upstream layout) +│ ├── Dockerfile +│ ├── cloud-run.testnet.yaml +│ ├── cloud-run.mainnet.yaml +│ ├── register.sh +│ └── allowed_leaders.testnet.json # placeholder; real one mounted via Secret Manager +└── src/ + ├── main.rs # #![doc = include_str!("../README.md")] + bootstrap!([...]) + ├── error.rs # ErrorKind enum + ErrorResponse + status/api-type mappers + ├── _client.rs # reqwest Client wrapper; .get/.post with typed error + └── tools/ + ├── mod.rs # const _API_BASE; pub(crate) mod ; shared deserializers + ├── models.rs # Shared response types used by ≥2 endpoint modules + ├── .rs # One file per endpoint = one NexusTool impl + └── .rs +``` + +## Per-endpoint file template + +```rust +//! # `xyz.taluslabs...@1` +//! +//! Standard Nexus Tool that from . + +use { + crate::{ + _client::Client, + error::ErrorKind, + tools::{ /* shared deserializers, const _API_BASE, models */ }, + }, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + /// + field: String, + optional_field: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + /// + result: String, + }, + Err { + reason: String, + kind: ErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct { + client: Client, +} + +impl NexusTool for { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { client: Client::new(None) } + } + + fn fqn() -> ToolFqn { + fqn!("xyz.taluslabs...@1") + } + + fn path() -> &'static str { "/" } + + fn description() -> &'static str { "" } + + async fn health(&self) -> AnyResult { Ok(StatusCode::OK) } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + // 1. Validate the input (return Err variant for invalid combinations). + // 2. Build the endpoint path. + // 3. Call self.client.get / .post. + // 4. Map success/error into Output::Ok / Output::Err. + } +} + +#[cfg(test)] +mod tests { + use { super::*, mockito::Server, serde_json::json }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, ) { + let server = Server::new_async().await; + let client = Client::new(Some(&server.url())); + (server, { client }) + } + + // happy path, error path, deserialization edge cases… +} +``` + +## main.rs template + +```rust +#![doc = include_str!("../README.md")] +#![allow(clippy::large_enum_variant)] + +use nexus_toolkit::bootstrap; + +mod _client; +mod error; +mod tools; + +#[tokio::main] +async fn main() { + bootstrap!([ + tools::::, + tools::::, + ]); +} +``` + +## tools/mod.rs template + +```rust +//! endpoints. + +pub(crate) const _API_BASE: &str = "https://api..com"; + +pub(crate) mod ; +pub(crate) mod ; +pub(crate) mod models; + +// Shared custom deserializers go here. +``` + +## Cargo.toml template + +```toml +[package] +name = "-" +description = "" + +edition.workspace = true +version.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +readme.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +chrono.workspace = true +thiserror.workspace = true +tokio.workspace = true +reqwest = { workspace = true, features = ["json"] } +serde_json.workspace = true +serde.workspace = true +schemars.workspace = true + +nexus-toolkit.workspace = true +nexus-sdk.workspace = true + +[dev-dependencies] +mockito.workspace = true +``` + +## Reference example + +`tools/exchanges-coinbase/` — 3 endpoints (`get-spot-price`, +`get-product-ticker`, `get-product-stats`), shared `coinbase_client.rs`, +shared `error.rs`, shared `models.rs`, shared deserializer +(`deserialize_trading_pair`) in `tools/mod.rs`. Copy from it freely. diff --git a/.claude/skills/nexus-tool-builder/reference/faq.md b/.claude/skills/nexus-tool-builder/reference/faq.md new file mode 100644 index 0000000..5a300d0 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/reference/faq.md @@ -0,0 +1,103 @@ +# FAQ / pitfalls + +## "My tool builds but the runtime rejects the output schema" + +`Output` must be a Rust `enum`, not a struct. The Nexus runtime requires a +top-level `oneOf` in the JSON schema, which `schemars` only emits for +enums. Wrap your fields: + +```rust +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { /* ... */ }, + Err { /* ... */ }, +} +``` + +## "Two of my tools collide at the same path" + +`bootstrap!([ToolA, ToolB])` mounts each tool at `NexusTool::path()`. The +default is `""` (root). Always override `path()` for every tool when more +than one lives in the same crate: + +```rust +fn path() -> &'static str { "/get-spot-price" } +``` + +## "I see `unknown field 'foo'` deserializing valid input" + +Inputs have `#[serde(deny_unknown_fields)]`. Add the field to your `Input` +struct, or relax the attribute (only if upstream callers are trusted). + +## "I want my tool to call the same API across multiple endpoints — do I duplicate the client?" + +No. Create one `_client.rs` with a `Client` that has +`.get` / `.post` methods. Each endpoint's `NexusTool` impl holds an +instance: + +```rust +pub(crate) struct GetSpotPrice { + client: CoinbaseClient, +} +``` + +Tests inject a `mockito::Server` via `Client::new(Some(&server.url()))`. + +## "Where do I put shared response models?" + +`src/tools/models.rs`. Use it for `CoinbaseApiResponse` and similar +wrappers shared by ≥2 endpoint modules. Endpoint-specific shapes can live +in the endpoint file. + +## "How do I handle API auth (API key, OAuth)?" + +In `_client.rs`. Read the secret from an env var at +`Client::new`: + +```rust +let api_key = std::env::var("COINBASE_API_KEY").ok(); +``` + +Wire it in Cloud Run via Secret Manager (`secretKeyRef` in the service +YAML). Never log the key — `tracing` filters and `Display` impls have to +exclude it. + +## "How do I version a tool?" + +The `@1` suffix in the FQN is immutable per registered tool. Output-schema +changes ⇒ bump to `@2` and register a new module/endpoint alongside `@1`, +deprecating `@1` once consumers migrate. Non-schema changes reuse `@1`. + +## "My tool keeps timing out at 10s in Nexus but works locally" + +Override `NexusTool::timeout()`. Default is 10s; pick something below the +Leader's request budget but with enough headroom for upstream latency. + +```rust +fn timeout() -> Duration { Duration::from_secs(30) } +``` + +## "Do I need signed HTTP locally?" + +Not during development. Signed HTTP is enforced only when +`signed_http.mode = "required"` in `NEXUS_TOOLKIT_CONFIG_PATH`. The +templated `cloud-run..yaml` sets that variable; local dev with +`just tools run ` leaves it unset. + +## "Workspace `members` line — do I need to update it?" + +No. `Cargo.toml` declares `members = ["tools/*"]` so any new directory +under `tools/` is picked up automatically. + +## "What about `tools/.just`?" + +Yes, you do need to extend the build/check/test/fmt-check/clippy recipes +with `cargo +stable build --package --release` (and equivalents). +The skill does this for you. + +## "On-chain or off-chain?" + +On-chain if the logic must be verifiable on Sui and you don't need +Web2 / secrets / heavy compute. Off-chain otherwise. See +`reference/onchain-tools.md`. diff --git a/.claude/skills/nexus-tool-builder/reference/hosting-options.md b/.claude/skills/nexus-tool-builder/reference/hosting-options.md new file mode 100644 index 0000000..7bcb461 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/reference/hosting-options.md @@ -0,0 +1,55 @@ +# Hosting options for off-chain tools + +## v1 default: GCP Cloud Run + +This skill targets **Google Cloud Run** for v1 deployment. Rationale: + +- Talus's own `tool-communication.md` recommends conventional cloud (Caddy / + ALB / managed TLS). No first-party Talus-hosted service exists. +- The Nexus trust model relies on Ed25519 signed HTTP + economic staking, + not on host decentralization. A "more decentralized" host buys almost no + trust benefit today. +- Cloud Run gives managed TLS, scale-to-zero, Workload Identity for + secrets, and Cloud Logging out of the box. Lowest friction for shipping. + +The `templates/deploy/` folder emits: + +- A multi-stage `Dockerfile` (Rust builder → distroless runtime, port 8080). +- `cloud-run.testnet.yaml` and `cloud-run.mainnet.yaml` (one Cloud Run + service per env, secret refs, allowed-leaders config). +- `register.sh` (idempotent `nexus tool register` / update). +- Two GitHub Actions workflows (testnet on push to `main`, mainnet on tag). + +## Alternatives (per-tool migration targets) + +| Provider | Fit | Trade-off | +|---|---|---| +| **Akash Network** | Best general DePIN fit. Mature compute marketplace, supports arbitrary Docker HTTPS services. | More ops friction than Cloud Run; debugging tooling weaker. | +| **Spheron** | Web3 cloud aggregator on top of AWS / Akash. Closest to managed-deploy UX with a DePIN backend. | Newer; smaller ecosystem than Cloud Run. | +| **Atoma Network** | Sui-native AI inference DePIN. Best fit when the tool itself is LLM inference. | Not general-purpose hosting — inference workloads only. | +| **Walrus** | Sui-native blob storage DePIN. Orthogonal — use for tool artifacts, response cache, model weights. | Not a service-hosting platform. | +| **Fluence / io.net / Aethir / Render** | Niche compute DePINs. | P2P or GPU-specific; overkill for typical API wrappers. | +| **AWS Cloud Run-equivalent / Fly.io / Render.com** | Drop-in alternatives if the team prefers another vendor. | Same trade-offs as Cloud Run; no Web3 angle. | + +## Migration path + +The `Dockerfile` the skill emits is portable. To move a single tool to +Akash or Spheron later: + +1. Push the existing image to a public registry (Docker Hub / GHCR). +2. Write a small SDL (Akash) or Spheron config that references the image. +3. Run `nexus tool register offchain --tool-fqn --url ` to + point Nexus at the new URL. Idempotent — the FQN stays the same. + +No Rust code changes required. + +## When to revisit this decision + +- **Cost.** If Cloud Run egress / minimum instances become material at + scale, Akash is meaningfully cheaper for steady-state workloads. +- **Narrative.** If the team is marketing Talus as a fully decentralized + stack, hosting tools on GCP undermines the story. Pick a flagship tool + (the one most-mentioned in talks) and deploy it to Akash as the + publicly-visible example. +- **First-party LLM tools.** When Talus ships its own LLM-inference tool + crate, deploy it to Atoma — the workload is the DePIN service. diff --git a/.claude/skills/nexus-tool-builder/reference/interface.md b/.claude/skills/nexus-tool-builder/reference/interface.md new file mode 100644 index 0000000..125dde2 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/reference/interface.md @@ -0,0 +1,76 @@ +# `NexusTool` trait contract and `bootstrap!` + +Source: `nexus-sdk/toolkit-rust/src/nexus_tool.rs`. + +## Required associated types + +| Type | Constraints | Purpose | +|---|---|---| +| `Input` | `JsonSchema + DeserializeOwned + Send` | Generates the input schema. Deserialized from the request body. | +| `Output` | `JsonSchema + Serialize + Send`, **must be a Rust `enum`** so the schema gets a top-level `oneOf` | Generates the output schema. Serialized into the response. | + +## Required methods + +| Method | Signature | Notes | +|---|---|---| +| `fqn` | `fn fqn() -> ToolFqn` | Use the `fqn!("xyz.taluslabs...@1")` macro. | +| `new` | `async fn new() -> Self` | Called per request. Inject dependencies here. | +| `invoke` | `async fn invoke(&self, input: Self::Input) -> Self::Output` | Main logic. **No `Result`** — errors go in `Output::Err`. | +| `health` | `async fn health(&self) -> AnyResult` | Check dependent services. Return `Ok(StatusCode::OK)` when healthy. | + +## Optional methods (have defaults) + +| Method | Default | When to override | +|---|---|---| +| `path` | `""` (root) | When multiple tools live in one crate — each needs a unique path. | +| `description` | `""` | Always — surfaces in `/meta`. | +| `timeout` | `Duration::from_secs(10)` | Override for slow upstreams; keep below the Leader's request budget. | +| `authorize` | `Ok(())` | Tool-side allowlists / rate-limits using the verified `AuthContext`. | + +## Generated endpoints (from `NexusTool` impl) + +| Endpoint | Body | +|---|---| +| `GET /health` | Status code returned by `health()`. | +| `GET /meta` | JSON: `{ fqn, url, timeout, description, input_schema, output_schema }`. | +| `POST /invoke` | Deserializes body as `Input`, calls `invoke`, serializes the result as `Output`. | + +## `bootstrap!` macro + +```rust +// Single tool, default 127.0.0.1:8080 (or $BIND_ADDR). +bootstrap!(MyTool); + +// Multiple tools, default address. Each NexusTool::path() must be unique. +bootstrap!([MyTool, MyOtherTool]); + +// Single tool, custom address. +bootstrap!(([0, 0, 0, 0], 8081), MyTool); + +// Multiple tools, custom address. +bootstrap!(([0, 0, 0, 0], 8081), [MyTool, MyOtherTool]); +``` + +## Output enum convention + +```rust +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + // flat ports; crucial fields non-optional + }, + Err { + reason: String, + kind: ErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} +``` + +`#[serde(rename_all = "snake_case")]` turns the `Ok` / `Err` variants into +the lowercase `ok` / `err` Nexus expects. + +Variant names beginning with `err` are treated by Nexus as error variants — +their ports are automatically passed on-chain regardless of DAG edges. diff --git a/.claude/skills/nexus-tool-builder/reference/onchain-tools.md b/.claude/skills/nexus-tool-builder/reference/onchain-tools.md new file mode 100644 index 0000000..d8b7e63 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/reference/onchain-tools.md @@ -0,0 +1,131 @@ +# On-chain Nexus tools (Sui Move) + +On-chain tools are Move modules that execute inside a Sui transaction. There +is no separate hosting — the Sui validator network runs them. Higher +per-call cost (Sui gas), zero infra to maintain. + +## When to choose on-chain + +- The tool's effect must be verifiable / auditable on-chain. +- The tool modifies on-chain state (transfers, registry writes, NFT mints). +- Trust-minimization matters more than per-call cost. +- Pure deterministic logic with no Web2 dependency. + +If the tool calls an external HTTP API, needs secrets, or does heavy +compute, build it off-chain (Rust). On-chain tools are for the +verifiability layer. + +## Module shape (from `onchain-tool-development.md`) + +```move +module my_onchain_tool::my_onchain_tool; + +use nexus_primitives::data; +use nexus_primitives::proof_of_uid::ProofOfUID; +use nexus_primitives::tagged_output::{Self, TaggedOutput}; +use sui::bag::{Self, Bag}; +use sui::clock::Clock; +use sui::transfer::share_object; +use std::ascii::String as AsciiString; + +public struct MY_ONCHAIN_TOOL has drop {} + +public struct MyToolWitness has key, store { id: UID } + +public struct MyToolState has key { + id: UID, + witness: Bag, + // application-specific fields here +} + +public enum Output { + Ok { result: u64 }, + Err { reason: AsciiString }, + // additional variants as needed; names starting with `err` are + // treated as error variants by Nexus. +} + +fun init(_otw: MY_ONCHAIN_TOOL, ctx: &mut TxContext) { + let state = MyToolState { + id: object::new(ctx), + witness: { + let mut bag = bag::new(ctx); + bag.add(b"witness", MyToolWitness { id: object::new(ctx) }); + bag + }, + }; + share_object(state); +} + +/// CRITICAL REQUIREMENTS: +/// 1. First parameter: `worksheet: &mut ProofOfUID` +/// 2. Last parameter: `ctx: &mut TxContext` +/// 3. Return type: `TaggedOutput` +/// 4. Stamp the worksheet with the witness ID before returning. +public fun execute( + worksheet: &mut ProofOfUID, + state: &mut MyToolState, + input_value: u64, + clock: &Clock, + ctx: &mut TxContext, +): TaggedOutput { + let witness = state.witness(); + worksheet.stamp_with_data(&witness.id, b"my_tool_executed"); + + if (input_value == 0) { + tagged_output::new(b"err") + .with_named_payload(b"reason", data::inline_one(b"Input value cannot be zero").as_string()) + } else { + let result = input_value * 2; + tagged_output::new(b"ok") + .with_named_payload(b"result", data::inline_one(result.to_string().into_bytes()).as_number()) + } +} + +fun witness(self: &MyToolState): &MyToolWitness { + self.witness.borrow(b"witness") +} + +public fun witness_id(self: &MyToolState): ID { + self.witness().id.to_inner() +} + +#[test_only] +public fun init_for_test(otw: MY_ONCHAIN_TOOL, ctx: &mut TxContext) { + init(otw, ctx); +} +``` + +## TaggedOutput value typing + +```move +.with_named_payload(b"count", data::inline_one(value.to_string().into_bytes()).as_number()) +.with_named_payload(b"message", data::inline_one(b"hello").as_string()) +.with_named_payload(b"success", data::inline_one(b"true").as_bool()) +.with_named_payload(b"sender", data::inline_one(address.to_string().into_bytes()).as_address()) +.with_named_payload(b"metadata", data::inline_one(b"{\"k\":\"v\"}").as_raw()) +.with_named_payload(b"items", data::inline_many(items).as_number()) +``` + +## Deployment + +```sh +# 1. Publish the Move package. +sui client publish --gas-budget 200000000 --json +# Capture packageId and the witness object id from the output. + +# 2. Register with Nexus. +nexus tool register onchain \ + --module-path "$PACKAGE_ID::my_onchain_tool" \ + --tool-fqn "xyz.taluslabs..@1" \ + --description "" \ + --witness-id "$WITNESS_ID" + +# 3. Verify. +nexus tool list +``` + +The witness ID lives in a dynamic field of the shared state object: +`0x2::dynamic_field::Field, $PACKAGE_ID::my_onchain_tool::MyToolWitness>`. + +Find it via the Sui explorer or `sui client object `. diff --git a/.claude/skills/nexus-tool-builder/reference/security-checklist.md b/.claude/skills/nexus-tool-builder/reference/security-checklist.md new file mode 100644 index 0000000..cdc86b1 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/reference/security-checklist.md @@ -0,0 +1,111 @@ +# Security checklist (authoritative) + +The `nexus-tool-auditor` agent uses this list as its source of truth. Items +are grouped by severity; the agent must report every applicable item in +its `AUDIT.md` output. + +## Off-chain (Rust) — CRITICAL + +| # | Check | Why it matters | +|---|---|---| +| C1 | `Output` is a Rust `enum` (not a struct) with `#[serde(rename_all = "snake_case")]` | Nexus runtime rejects non-`oneOf` schemas. | +| C2 | No `unwrap()` / `expect()` / `panic!` on any path reachable from `invoke()` | Panics surface as opaque 500s; Nexus expects typed `Err` variants. | +| C3 | No `danger_accept_invalid_certs` or any TLS bypass | Leader-side TLS verification is part of the Nexus trust model. | +| C4 | No hardcoded API keys, tokens, or signing material in source or test fixtures | Source leaks become public on first push. | +| C5 | `Output::Err::reason` does NOT contain raw upstream bodies, request URLs with secrets, internal file paths, or stack frames | Errors are passed on-chain unredacted. | +| C6 | Tool does NOT call `std::process::exit`, `unsafe`, or spawn child processes | Breaks the toolkit's lifecycle assumptions. | +| C7 | Tool source MUST NOT read any upstream-API credential from env vars, disk, or any persistent store. Credentials live ONLY in `Input` struct fields. | Repo-wide convention; matches `tools/llm-openai-chat-completion`, `tools/social-twitter`. Reading credentials from env breaks multi-tenancy and concentrates secret material on the Tool host. | +| C8 | Tool MUST NOT log, print, format-debug, or include in `Output::Err::reason` any value of a credential field on `Input`. `Input` MUST NOT derive `Debug` automatically — if you need Debug, hand-write one that omits credential fields. | Logs end up in Cloud Logging where many people can read them. | +| C9 | Tool MUST be **stateless across invocations**. No `static`, `lazy_static`, `OnceLock`, `Mutex`, `RwLock`, `Cell`, `RefCell` carrying per-request data. No on-disk writes outside `#[cfg(test)]`. Same input ⇒ same upstream call. | `nexus-toolkit` calls `new()` per request; stateful tools produce non-deterministic outputs, break retries, and turn the Tool host into a trust point. Configuration constants (URLs, version strings) are fine; mutable shared state is not. | +| C10 | Tool MUST NOT mount upstream API keys as Cloud Run secrets. The only secrets bound to the Cloud Run service are `nexus-toolkit-config--` (Tool's own Ed25519 signing key) and `nexus-allowed-leaders-` (public keys). | Custodying upstream keys on the Tool host violates the credential model in C7 and creates a juicy exfiltration target. | +| C11 | The crate's `README.md` and any example DAG JSON MUST NOT contain credential-shaped values (`sk_live_`, `sk_test_<64 chars>`, `Bearer `). Use placeholder names like `$STRIPE_API_KEY`. | DAG `default_values` get written to Sui permanently. Real keys in READMEs leak via git history. | + +## Off-chain — HIGH + +| # | Check | Why it matters | +|---|---|---| +| H1 | `Input` has `#[serde(deny_unknown_fields)]` | Stops silent acceptance of typos / injection of unknown control fields. | +| H2 | Every `NexusTool::path()` is unique within the crate | `bootstrap!` will mount duplicates at the same route; behavior is undefined. | +| H3 | `NexusTool::timeout()` is overridden to a value below the Leader request budget but above 2× expected upstream latency | Default 10s is too short for many real APIs and too long for cheap ones. | +| H4 | All `reqwest::Client` instances set a stable user-agent | Some APIs (Coinbase) reject empty user-agents intermittently. | +| H5 | `cargo audit` reports no known advisories | Public CVE; pre-existing. | +| H6 | API keys read via `std::env::var` are never logged with `tracing` / `log` / `println!` | Logs end up in Cloud Logging, accessible to anyone with project read. (Note: per C7 you shouldn't be reading them from env at all.) | +| H7 | Every credential-bearing `Input` field is named on the convention `api_key`, `bearer_token`, `*_secret`, `*_token`, so the auditor's name-pattern detector can find them. | The auditor can't tell a port is sensitive otherwise. | +| H8 | Write endpoints (POST/PUT/PATCH/DELETE) include `idempotency_key: Option` in `Input` and pass it through as the `Idempotency-Key` header. | Leaders retry on transient failures; without this, retries double-charge / double-write. | +| H9 | For Stripe-style APIs: tests / fixtures use `sk_test_` prefixed keys only. No `sk_live_`, `pk_live_`, or other production-prefix tokens anywhere in source. | Bricks of fines and reputation damage if a real live key lands in git. | +| H10 | Stripe-style tools cover every documented error class in `from_api_error_type`: `card_error`, `validation_error`, `invalid_request_error`, `idempotency_error`, `rate_limit_error`, `authentication_error`, `api_error`. | Unmapped error types degrade to `Unknown`, hiding the actual failure mode from DAG authors. | + +## Off-chain — MEDIUM + +| # | Check | Why it matters | +|---|---|---| +| M1 | `NexusTool::description()` is non-empty | `/meta` exposes this to DAG authors. | +| M2 | Every endpoint has a `mockito`-backed happy-path test | Regressions caught at PR time. | +| M3 | Every endpoint has at least one error-variant test | Same. | +| M4 | Crucial response fields are non-optional in `Output::Ok` | Forces `Err` variant when upstream omits required data. | +| M5 | No `println!` / `eprintln!` / `dbg!` outside `#[cfg(test)]` code | Pollutes Cloud Logging; can leak data. | +| M6 | Workspace dep versions (`workspace = true`) — no per-crate version drift | Hard-to-trace upgrade pain. | + +## Off-chain — LOW / INFO + +| # | Check | Why it matters | +|---|---|---| +| L1 | Doc comments on every `Input` / `Output` field — they show up in `input_schema` / `output_schema` | DAG authors need this. | +| L2 | `Cargo.toml` `description` is set | Helps `cargo metadata` consumers. | +| L3 | README has one section per FQN | Convention; consumed by tooling. | + +## On-chain (Move) — CRITICAL + +| # | Check | Why it matters | +|---|---|---| +| C-M1 | `execute` first parameter is `worksheet: &mut ProofOfUID`, last is `ctx: &mut TxContext`, returns `TaggedOutput` | Runtime enforces this signature. | +| C-M2 | `worksheet.stamp_with_data(&witness.id, …)` is called on **every** code path before `execute` returns | Without it the Nexus runtime rejects the proof. Easy to miss in early-return branches. | +| C-M3 | Witness struct does NOT have the `copy` ability | A copyable witness lets anyone forge proofs. | +| C-M4 | Public functions that mutate shared state require an explicit capability (e.g. `AdminCap`) | Anyone can call `public fun` on Sui — without a cap, anyone can drain or alter the tool. | +| C-M5 | No `public entry fun` that calls `transfer::public_transfer(state, recipient)` on the tool's shared state | Lets a random caller hijack the tool. | +| C-M6 | `init` mints any `AdminCap` and transfers it to the publisher (`tx_context::sender(ctx)`) | Otherwise the cap is unminted or stuck in the package. | + +## On-chain — HIGH + +| # | Check | Why it matters | +|---|---|---| +| H-M1 | Output enum has at least one `err`-prefixed variant | Nexus treats them as error variants. | +| H-M2 | TaggedOutput field types use the right `as_*()` (number/string/bool/address/raw) | Wrong typing breaks downstream tools at the schema layer, after the Move call succeeded. | +| H-M3 | No unbounded loops over caller-controlled `vector` or dynamic field reads | Gas grief / out-of-budget aborts. | +| H-M4 | Arithmetic that may overflow uses `checked_*` (or relies on Move's abort behavior deliberately, with a comment) | Silent abort is fine only when documented. | +| H-M5 | `Move.toml` upgrade policy is explicit (`compatible` or `immutable`) | Default is `compatible`; for production-critical tools, switch to `immutable` after stabilization. | +| H-M6 | `sui move test` covers every error variant | Regression net. | + +## On-chain — MEDIUM + +| # | Check | Why it matters | +|---|---|---| +| M-M1 | No `friend` modules outside this package | `friend` widens access; reviewers need to follow the trust path. | +| M-M2 | Every shared object is initialized in `init` and not re-creatable | Drift between the published state and the registered witness id breaks Nexus. | +| M-M3 | Test for the witness-stamping flow (positive assertion that the worksheet has the expected stamp after `execute`) | Catches silent misuse of `stamp_with_data`. | + +## On-chain — LOW / INFO + +| # | Check | Why it matters | +|---|---|---| +| L-M1 | Doc comments on `execute` and on every `Output` variant | Schema generation reads them. | +| L-M2 | Module path matches the FQN convention `..@` | Convention; helps discovery. | + +## Cross-cutting (both kinds) + +| # | Check | Why it matters | +|---|---|---| +| X1 | FQN version (`@1`) is bumped when output schema changes | Existing DAGs break otherwise. | +| X2 | `description` (Rust) / module-level docstrings (Move) accurately describe behavior | Misleading descriptions break DAG composition by humans and agents. | +| X3 | Tool is **idempotent** under retry — same input ⇒ same output, side-effect-free for read tools | The Leader retries on transient failures. | +| X4 | Tool does NOT trust the request body for authentication — only the signed-HTTP layer (off-chain) or the worksheet (on-chain) | Bypass otherwise. | + +## How the auditor maps findings to severity + +- **CRITICAL**: production-shipping the tool with this finding open exposes + funds, secrets, or DAG correctness. +- **HIGH**: causes incorrect behavior or significantly weakens defense in + depth. Block mainnet, allow testnet with a follow-up issue. +- **MEDIUM**: degraded UX, missing test, or non-blocking conformance gap. + Allow testnet; fix before mainnet. +- **LOW / INFO**: style, polish, doc. diff --git a/.claude/skills/nexus-tool-builder/reference/style-guide.md b/.claude/skills/nexus-tool-builder/reference/style-guide.md new file mode 100644 index 0000000..0e30d0a --- /dev/null +++ b/.claude/skills/nexus-tool-builder/reference/style-guide.md @@ -0,0 +1,74 @@ +# Tool design rules (enforced by the skill) + +Distilled from `Talus-Network/nexus-sdk/docs/tool-development.md`. + +## Naming + +- All port names (Input Ports, Output Variants, Output Ports) are + `snake_case`. Never `camelCase` / `PascalCase` / `APIKey`. +- Names are descriptive and concise: `api_key`, not `k` or `apk`. +- Erroneous output variants start with `err`: `err`, `err_http`, `err_quota`. + Never `error`, `failure`, `http_exception`. + +## Erroneous variants + +Any variant whose name starts with `err` is treated by Nexus as an error +variant. All ports in an error variant are passed on-chain regardless of +DAG edges. Use this: + +```rust +Err { + reason: String, // human-readable + kind: ErrorKind, // machine-readable enum + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, +} +``` + +## Interface design + +| ✅ Do | ❌ Don't | +|---|---| +| Build a tool that encapsulates the API's surface (one tool per endpoint, parameterized). | Build a tool that only does one hardcoded call (e.g. "BTC-USD spot price"). | +| Split `prompt` and `context` into separate input ports even if the API merges them. | Merge them into one input port — the DAG can't set defaults for fields combined with edge data. | +| Accept `json_schema` as input and validate generic responses against it (where applicable). | Hardcode the output schema for a single endpoint when the underlying API serves many. | +| Make crucial output ports non-optional. Return `err` if data is missing. | Make crucial output ports `Option` and force every downstream tool to null-check. | +| Keep output ports flat: `ok.id`, `ok.text`. | Nest output ports: `ok.response.tweet.id`. The next tool can't bind to that. | + +## Documentation + +Every crate has a `README.md` with one section per FQN: + +```markdown +# `xyz.taluslabs...@1` + + API [reference](https://...). + +## Input + +**`field`: [`Type`]** — description. +**`optional`: [`Type`] (optional)** — description. + +## Output Variants & Ports + +**`ok`** — . +- **`ok.field`: [`Type`]** — description. + +**`err`** — . +- **`err.reason`: [`String`]** +- **`err.kind`: [`String`]** +- **`err.status_code`: [`u16`] (optional)** + +--- +``` + +`main.rs` must include the README via: + +```rust +#![doc = include_str!("../README.md")] +``` + +## Validate first, then call + +Inside `invoke`, validate input before making any external call. Surface +validation failures as `Output::Err { kind: ErrorKind::InvalidRequest, status_code: None, ... }`. diff --git a/.claude/skills/nexus-tool-builder/scripts/audit.sh b/.claude/skills/nexus-tool-builder/scripts/audit.sh new file mode 100755 index 0000000..cc7f004 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/scripts/audit.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +# +# audit.sh — mechanical static + dynamic audit of a Nexus tool crate. +# Invoked by the nexus-tool-auditor agent; can also be run by hand. +# +# Usage: +# audit.sh off-chain +# audit.sh on-chain + +set -uo pipefail + +KIND="${1:?missing first arg: off-chain|on-chain}" +TARGET="${2:?missing second arg: crate name (off-chain) or path (on-chain)}" + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$REPO_ROOT" + +# Each check writes a single line "STATUS\tCHECK\tDETAIL" to the report. +REPORT="$(mktemp)" +PASS=0 +FAIL=0 + +emit() { + local status="$1" check="$2" detail="${3:-}" + printf '%s\t%s\t%s\n' "$status" "$check" "$detail" >> "$REPORT" + case "$status" in + PASS) PASS=$((PASS+1));; + FAIL|WARN) FAIL=$((FAIL+1));; + esac +} + +require() { + if command -v "$1" >/dev/null 2>&1; then return 0; fi + emit WARN "tooling:$1" "not installed — skipping checks that need it" + return 1 +} + +off_chain() { + local crate="$1" + local crate_dir="tools/$crate" + + if [[ ! -d "$crate_dir" ]]; then + emit FAIL "crate:exists" "$crate_dir not found" + return + fi + + # ---- Static: cargo ------------------------------------------------------ + if require cargo; then + if cargo +stable check --all-targets --package "$crate" 2>&1 | tee /tmp/audit-check.log | tail -5 >/dev/null; then + if grep -q 'error\[' /tmp/audit-check.log; then + emit FAIL "cargo:check" "see /tmp/audit-check.log" + else + emit PASS "cargo:check" + fi + else + emit FAIL "cargo:check" "non-zero exit" + fi + + if cargo +stable clippy --all-targets --all-features --package "$crate" -- -D warnings 2>&1 | tee /tmp/audit-clippy.log | tail -5 >/dev/null; then + if grep -q 'warning:\|error\[' /tmp/audit-clippy.log; then + emit FAIL "cargo:clippy" "see /tmp/audit-clippy.log" + else + emit PASS "cargo:clippy" + fi + else + emit FAIL "cargo:clippy" "non-zero exit" + fi + + if cargo +stable test --no-run --package "$crate" 2>&1 | tail -5 >/dev/null; then + emit PASS "cargo:test-compiles" + else + emit FAIL "cargo:test-compiles" + fi + fi + + if require cargo-audit; then + if cargo audit 2>&1 | tee /tmp/audit-audit.log | grep -q 'Crate:'; then + emit FAIL "cargo:audit" "advisories present — see /tmp/audit-audit.log" + else + emit PASS "cargo:audit" + fi + fi + + if require cargo-deny; then + if cargo deny check 2>&1 | tail -5 >/dev/null; then + emit PASS "cargo:deny" + else + emit WARN "cargo:deny" "non-zero exit" + fi + fi + + # ---- Static: greps ------------------------------------------------------ + # 1. unwrap/expect/panic — flag any usage in non-test code. Caller can + # rule out startup-only unwraps (StripeClient::new builder) when + # writing AUDIT.md, but every site needs eyeballing. + : > /tmp/audit-unwrap.log + while IFS= read -r f; do + grep -nE '\b(unwrap|expect|panic!)\b' "$f" 2>/dev/null \ + | grep -v 'mod tests' \ + | awk -v file="$f" -F: '{print file":"$0}' \ + >> /tmp/audit-unwrap.log || true + done < <(find "$crate_dir/src" -name '*.rs' -not -path '*/tests/*') + # Exclude lines inside #[cfg(test)] modules. Simple heuristic: drop + # any file path that already contains `tests` in its name; for + # in-file `mod tests {}` blocks, the `mod tests` grep -v above + # excludes the contents heuristically. + if [[ -s /tmp/audit-unwrap.log ]]; then + emit WARN "static:unwrap-in-non-test" "$(wc -l /tmp/audit-println.log || true + if [[ -s /tmp/audit-println.log ]]; then + emit WARN "static:println" "$(wc -l /dev/null; then + emit FAIL "static:disabled-tls-verify" "danger_accept_invalid_certs present" + else + emit PASS "static:disabled-tls-verify" + fi + + # 4. hardcoded secrets (heuristic) + if grep -rEn '(api[_-]?key|secret|token|bearer)\s*=\s*"[A-Za-z0-9_\-]{16,}"' "$crate_dir/src" --include='*.rs' >/tmp/audit-secrets.log; then + if [[ -s /tmp/audit-secrets.log ]]; then + emit WARN "static:hardcoded-secret" "see /tmp/audit-secrets.log" + else + emit PASS "static:hardcoded-secret" + fi + else + emit PASS "static:hardcoded-secret" + fi + + # 4b. Stripe-style live-key leak detector. Require ≥16 alnum chars + # after the prefix to avoid matching README explanatory text like + # "sk_live_..." or "use sk_live_ for production". + if grep -rEn '(sk|pk|rk)_live_[A-Za-z0-9]{16,}' "$crate_dir" --include='*.rs' --include='*.md' --include='*.json' --include='*.yaml' --include='*.yml' >/tmp/audit-livekey.log; then + if [[ -s /tmp/audit-livekey.log ]]; then + emit FAIL "static:live-key-leak" "see /tmp/audit-livekey.log" + else + emit PASS "static:live-key-leak" + fi + else + emit PASS "static:live-key-leak" + fi + + # 4c. credentials sourced from env (C7 violation) + if grep -rEn 'std::env::var\(\s*"[A-Z_]*(KEY|SECRET|TOKEN|PASSWORD|BEARER)' "$crate_dir/src" --include='*.rs' >/tmp/audit-env-cred.log; then + if [[ -s /tmp/audit-env-cred.log ]]; then + emit FAIL "static:env-credential" "C7 violation: credentials must come via Input, not env. See /tmp/audit-env-cred.log" + else + emit PASS "static:env-credential" + fi + else + emit PASS "static:env-credential" + fi + + # 4d. statefulness sniff (C9): mutable shared state across requests + if grep -rEn '\b(lazy_static!|OnceLock|once_cell|Mutex<|RwLock<|RefCell<|static mut|AtomicU)' "$crate_dir/src" --include='*.rs' \ + | grep -v 'mod tests' >/tmp/audit-stateful.log; then + if [[ -s /tmp/audit-stateful.log ]]; then + emit FAIL "static:stateful" "C9 violation: tool must be stateless. See /tmp/audit-stateful.log" + else + emit PASS "static:stateful" + fi + else + emit PASS "static:stateful" + fi + + # 4e. on-disk writes outside tests + if grep -rEn 'std::fs::(write|create|create_dir|File::create|File::open\(\s*[^"])' "$crate_dir/src" --include='*.rs' \ + | grep -v 'mod tests' >/tmp/audit-fs.log; then + if [[ -s /tmp/audit-fs.log ]]; then + emit WARN "static:on-disk-write" "C9 candidate: persistent fs ops. See /tmp/audit-fs.log" + else + emit PASS "static:on-disk-write" + fi + else + emit PASS "static:on-disk-write" + fi + + # 4f. Debug derived on a struct that contains api_key (C8 violation) + if grep -rEn -B3 'pub api_key:|pub bearer_token:|pub .*_secret:|pub .*_token:' "$crate_dir/src" --include='*.rs' \ + | grep -E '#\[derive\([^)]*Debug' >/tmp/audit-debug-cred.log; then + if [[ -s /tmp/audit-debug-cred.log ]]; then + emit FAIL "static:debug-on-credentials" "C8 violation: Debug derived near credential field. See /tmp/audit-debug-cred.log" + else + emit PASS "static:debug-on-credentials" + fi + else + emit PASS "static:debug-on-credentials" + fi + + # 4g. Cloud Run YAML should not reference upstream-API-key secrets (C10) + local cr_yaml + cr_yaml="$crate_dir/deploy" + if [[ -d "$cr_yaml" ]] && grep -rEn 'secretName:' "$cr_yaml" >/tmp/audit-secrets-mount.log 2>/dev/null; then + if grep -vE 'nexus-toolkit-config|nexus-allowed-leaders' /tmp/audit-secrets-mount.log >/tmp/audit-secrets-mount-bad.log; then + if [[ -s /tmp/audit-secrets-mount-bad.log ]]; then + emit FAIL "deploy:upstream-key-mounted" "C10 violation: unexpected secret in Cloud Run YAML. See /tmp/audit-secrets-mount-bad.log" + else + emit PASS "deploy:upstream-key-mounted" + fi + else + emit PASS "deploy:upstream-key-mounted" + fi + fi + + # 5. Output is enum (look for `enum Output` not `struct Output`) + if grep -rn 'enum Output' "$crate_dir/src" --include='*.rs' >/dev/null; then + emit PASS "conform:output-enum" + else + emit FAIL "conform:output-enum" "no enum Output found — Nexus requires top-level oneOf" + fi + + # 6. deny_unknown_fields on every Input + local input_files + input_files="$(grep -rEln 'struct Input\b' "$crate_dir/src" --include='*.rs' || true)" + if [[ -n "$input_files" ]]; then + while IFS= read -r f; do + if ! grep -B2 'struct Input\b' "$f" | grep -q 'deny_unknown_fields'; then + emit WARN "conform:deny-unknown-fields" "$f" + fi + done <<< "$input_files" + fi + + # 7. description() override + if grep -rn 'fn description' "$crate_dir/src" --include='*.rs' >/dev/null; then + emit PASS "conform:description" + else + emit WARN "conform:description" "no description() override — /meta will show empty" + fi +} + +on_chain() { + local pkg="$1" + + if [[ ! -d "$pkg" ]]; then + emit FAIL "pkg:exists" "$pkg not found" + return + fi + + cd "$pkg" + + if require sui; then + if sui move build 2>&1 | tee /tmp/audit-move-build.log | tail -5 >/dev/null; then + if grep -qi 'error' /tmp/audit-move-build.log; then + emit FAIL "sui:move-build" + else + emit PASS "sui:move-build" + fi + else + emit FAIL "sui:move-build" "non-zero exit" + fi + + if sui move test 2>&1 | tee /tmp/audit-move-test.log | tail -10 >/dev/null; then + if grep -q 'FAILED' /tmp/audit-move-test.log; then + emit FAIL "sui:move-test" + else + emit PASS "sui:move-test" + fi + else + emit FAIL "sui:move-test" "non-zero exit" + fi + + sui move prove 2>&1 | tail -10 > /tmp/audit-move-prove.log || true + emit PASS "sui:move-prove" "see /tmp/audit-move-prove.log (best-effort)" + fi + + # ---- Conformance greps -------------------------------------------------- + + # 1. execute() signature: first arg ProofOfUID, last arg TxContext, returns TaggedOutput + if grep -rEn 'public fun execute\(' sources --include='*.move' >/tmp/audit-execute.log; then + while IFS=: read -r f line _rest; do + block="$(sed -n "${line},+10p" "$f")" + if ! echo "$block" | grep -q 'worksheet: &mut ProofOfUID'; then + emit FAIL "move:execute-first-arg" "$f:$line" + fi + if ! echo "$block" | grep -q 'ctx: &mut TxContext'; then + emit FAIL "move:execute-ctx-arg" "$f:$line" + fi + if ! echo "$block" | grep -q '-> TaggedOutput'; then + emit FAIL "move:execute-return" "$f:$line" + fi + done < /tmp/audit-execute.log + emit PASS "move:execute-found" + else + emit WARN "move:execute-found" "no public fun execute(…) — is this a tool module?" + fi + + # 2. Worksheet stamping + if grep -rEn 'stamp_with_data\(' sources --include='*.move' >/dev/null; then + emit PASS "move:worksheet-stamp" + else + emit FAIL "move:worksheet-stamp" "no call to stamp_with_data — worksheet must be stamped" + fi + + # 3. Witness has key, store (no copy) + if grep -rEn 'struct \w+Witness has [^{]+copy' sources --include='*.move' >/dev/null; then + emit FAIL "move:witness-copy" "witness has copy ability — must not be copyable" + else + emit PASS "move:witness-copy" + fi + + # 4. Err variant in Output enum + if grep -rEn 'public enum Output' sources --include='*.move' >/dev/null; then + if grep -rEn '^[[:space:]]*Err[[:space:]]*\{' sources --include='*.move' >/dev/null; then + emit PASS "move:err-variant" + else + emit WARN "move:err-variant" "Output enum present but no Err variant — Nexus expects at least one err-prefixed variant" + fi + fi + + # 5. Unauthorized public entry functions touching state + if grep -rEn '^[[:space:]]*public entry fun ' sources --include='*.move' >/tmp/audit-entries.log; then + if [[ -s /tmp/audit-entries.log ]]; then + emit WARN "move:public-entry" "$(wc -l /dev/null +} + +case "$KIND" in + off-chain) off_chain "$TARGET" ;; + on-chain) on_chain "$TARGET" ;; + *) echo "unknown kind: $KIND"; exit 2 ;; +esac + +# ---- Final report ---------------------------------------------------------- +echo "==========================================" +echo "audit: $KIND $TARGET" +echo "==========================================" +column -t -s$'\t' "$REPORT" || cat "$REPORT" +echo "------------------------------------------" +echo "passed: $PASS failed/warned: $FAIL" +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi diff --git a/.claude/skills/nexus-tool-builder/scripts/new_tool.sh b/.claude/skills/nexus-tool-builder/scripts/new_tool.sh new file mode 100755 index 0000000..8d009f6 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/scripts/new_tool.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# new_tool.sh — wrap `nexus tool new` and seed local templates over the crate. +# +# Usage: +# new_tool.sh off-chain - # off-chain Rust tool +# new_tool.sh on-chain # on-chain Move tool +# +# Run from the nexus-tools repo root. + +set -euo pipefail + +KIND="${1:?missing first arg: off-chain|on-chain}" +NAME="${2:?missing second arg: crate or module name}" + +if ! command -v nexus >/dev/null 2>&1; then + echo "error: 'nexus' CLI not found in PATH" >&2 + echo " install: see https://github.com/Talus-Network/nexus-sdk/tree/main/cli" >&2 + exit 127 +fi + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +case "$KIND" in + off-chain) + if [[ -d "tools/$NAME" ]]; then + echo "tools/$NAME already exists; refusing to clobber" >&2 + exit 1 + fi + nexus tool new --name "$NAME" --template rust + mkdir -p tools + mv "$NAME" "tools/$NAME" + mkdir -p "tools/$NAME/deploy" + # Caller (the skill) will now render templates over tools/$NAME/. + echo "scaffolded tools/$NAME" + ;; + + on-chain) + if [[ -d "$NAME" ]]; then + echo "$NAME already exists; refusing to clobber" >&2 + exit 1 + fi + nexus tool new --name "${NAME}_onchain" --template move + echo "scaffolded ${NAME}_onchain" + ;; + + *) + echo "unknown kind: $KIND (expected off-chain|on-chain)" >&2 + exit 2 + ;; +esac diff --git a/.claude/skills/nexus-tool-builder/scripts/verify.sh b/.claude/skills/nexus-tool-builder/scripts/verify.sh new file mode 100755 index 0000000..c07c029 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/scripts/verify.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# verify.sh — run cargo check / clippy / test / fmt --check, then a smoke +# test of /health and /meta for every tool path the crate exposes. +# +# Usage: +# verify.sh + +set -euo pipefail + +CRATE="${1:?missing first arg: crate name}" +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +echo "==> cargo check" +cargo +stable check --package "$CRATE" + +echo "==> cargo clippy" +cargo +stable clippy --package "$CRATE" -- -D warnings + +echo "==> cargo test" +cargo +stable test --package "$CRATE" + +if command -v just >/dev/null 2>&1; then + NIGHTLY="$(just helpers::get-nightly-version 2>/dev/null || true)" + if [[ -n "$NIGHTLY" ]]; then + echo "==> cargo fmt --check (nightly $NIGHTLY)" + cargo "+$NIGHTLY" fmt --package "$CRATE" --check + else + echo "==> skipping fmt --check (nightly version not available)" + fi +else + echo "==> skipping fmt --check (just not installed)" +fi + +echo "==> smoke test (cargo run + curl /health + /meta)" +PORT=$(( RANDOM % 1000 + 38080 )) +BIND_ADDR="127.0.0.1:${PORT}" cargo +stable run --package "$CRATE" --release & +PID=$! +trap 'kill $PID 2>/dev/null || true' EXIT + +# Wait up to 10s for the server to bind. +for i in $(seq 1 50); do + if (echo > /dev/tcp/127.0.0.1/$PORT) 2>/dev/null; then break; fi + sleep 0.2 +done + +PATHS_FILE="tools/$CRATE/paths.json" +if [[ -f "$PATHS_FILE" ]]; then + mapfile -t PATHS < <(jq -r '.[]' "$PATHS_FILE") +else + PATHS=("") +fi + +for path in "${PATHS[@]}"; do + echo " checking ${path}/health" + curl -fsS "http://127.0.0.1:${PORT}${path}/health" >/dev/null + echo " checking ${path}/meta" + curl -fsS "http://127.0.0.1:${PORT}${path}/meta" | jq -e .fqn >/dev/null +done + +echo "==> ok" diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/Dockerfile.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/Dockerfile.tmpl new file mode 100644 index 0000000..d3991a3 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/deploy/Dockerfile.tmpl @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1.7 + +# ---- Builder ---------------------------------------------------------------- +FROM rust:1.83-bookworm AS builder +WORKDIR /build + +# Copy workspace manifests first so dependency builds cache cleanly. +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY tools/{{crate_name}}/Cargo.toml tools/{{crate_name}}/Cargo.toml +# Workspace `members = ["tools/*"]` requires every sibling to exist for the +# resolver. Easiest: copy the whole workspace. +COPY tools tools +COPY helpers helpers + +RUN cargo +stable build --release --package {{crate_name}} + +# ---- Runtime ---------------------------------------------------------------- +FROM gcr.io/distroless/cc-debian12:nonroot AS runtime +WORKDIR /app + +COPY --from=builder /build/target/release/{{crate_name}} /app/{{crate_name}} + +ENV BIND_ADDR=0.0.0.0:8080 +EXPOSE 8080 + +USER nonroot +ENTRYPOINT ["/app/{{crate_name}}"] diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/allowed_leaders.json.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/allowed_leaders.json.tmpl new file mode 100644 index 0000000..71f6efd --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/deploy/allowed_leaders.json.tmpl @@ -0,0 +1,9 @@ +{ + "_comment": "Placeholder. The real file is mounted via Secret Manager at /etc/nexus/allowed_leaders/.json. Populate via `nexus key list-leaders --network ` and commit only the ID/key pairs to the secret, not this file.", + "leaders": [ + { + "leader_id": "0x0000000000000000000000000000000000000000000000000000000000000000", + "public_key_ed25519_b64": "REPLACE_ME" + } + ] +} diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.mainnet.yaml.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.mainnet.yaml.tmpl new file mode 100644 index 0000000..b627277 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.mainnet.yaml.tmpl @@ -0,0 +1,61 @@ +# Credential model: this service holds NO upstream API credentials. +# The only secrets mounted here are: +# - nexus-toolkit-config-mainnet- (Tool's own Ed25519 signing key, tool_kid) +# - nexus-allowed-leaders-mainnet (public keys, not secret) +# Upstream API keys (Stripe, OpenAI, etc.) come in via the per-request +# `Input` struct, sourced by the Leader at DAG-execution time. Adding +# any other secretKeyRef fails the auditor's C10 check. +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: "{{crate_name}}-mainnet" + labels: + network: mainnet + tool-fqn: "{{fqn_label}}" + annotations: + run.googleapis.com/launch-stage: GA +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: "1" + autoscaling.knative.dev/maxScale: "20" + run.googleapis.com/execution-environment: gen2 + spec: + serviceAccountName: "nexus-tools-mainnet@${PROJECT_ID}.iam.gserviceaccount.com" + timeoutSeconds: 30 + containerConcurrency: 80 + containers: + - name: tool + image: "${REGISTRY}/{{crate_name}}:${SHA}" + ports: + - name: http1 + containerPort: 8080 + env: + - name: BIND_ADDR + value: 0.0.0.0:8080 + - name: NEXUS_TOOLKIT_CONFIG_PATH + value: /etc/nexus/toolkit.json + - name: NEXUS_NETWORK + value: mainnet + volumeMounts: + - name: nexus-config + mountPath: /etc/nexus + readOnly: true + - name: allowed-leaders + mountPath: /etc/nexus/allowed_leaders + readOnly: true + resources: + limits: + cpu: "2" + memory: 1Gi + volumes: + - name: nexus-config + secret: + secretName: "nexus-toolkit-config-mainnet-{{crate_name}}" + - name: allowed-leaders + secret: + secretName: nexus-allowed-leaders-mainnet + traffic: + - percent: 100 + latestRevision: true diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.testnet.yaml.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.testnet.yaml.tmpl new file mode 100644 index 0000000..fbaffbf --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.testnet.yaml.tmpl @@ -0,0 +1,61 @@ +# Credential model: this service holds NO upstream API credentials. +# The only secrets mounted here are: +# - nexus-toolkit-config-testnet- (Tool's own Ed25519 signing key, tool_kid) +# - nexus-allowed-leaders-testnet (public keys, not secret) +# Upstream API keys (Stripe, OpenAI, etc.) come in via the per-request +# `Input` struct, sourced by the Leader at DAG-execution time. Adding +# any other secretKeyRef fails the auditor's C10 check. +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: "{{crate_name}}-testnet" + labels: + network: testnet + tool-fqn: "{{fqn_label}}" + annotations: + run.googleapis.com/launch-stage: GA +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: "0" + autoscaling.knative.dev/maxScale: "5" + run.googleapis.com/execution-environment: gen2 + spec: + serviceAccountName: "nexus-tools-testnet@${PROJECT_ID}.iam.gserviceaccount.com" + timeoutSeconds: 30 + containerConcurrency: 80 + containers: + - name: tool + image: "${REGISTRY}/{{crate_name}}:${SHA}" + ports: + - name: http1 + containerPort: 8080 + env: + - name: BIND_ADDR + value: 0.0.0.0:8080 + - name: NEXUS_TOOLKIT_CONFIG_PATH + value: /etc/nexus/toolkit.json + - name: NEXUS_NETWORK + value: testnet + volumeMounts: + - name: nexus-config + mountPath: /etc/nexus + readOnly: true + - name: allowed-leaders + mountPath: /etc/nexus/allowed_leaders + readOnly: true + resources: + limits: + cpu: "1" + memory: 512Mi + volumes: + - name: nexus-config + secret: + secretName: "nexus-toolkit-config-testnet-{{crate_name}}" + - name: allowed-leaders + secret: + secretName: nexus-allowed-leaders-testnet + traffic: + - percent: 100 + latestRevision: true diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-mainnet.yml.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-mainnet.yml.tmpl new file mode 100644 index 0000000..e5fc28a --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-mainnet.yml.tmpl @@ -0,0 +1,89 @@ +name: deploy-{{crate_name}}-mainnet + +on: + push: + tags: + - "v{{crate_name}}-*" # e.g. v{{crate_name}}-1.0.0 + +permissions: + contents: read + id-token: write + +env: + PROJECT_ID: talus-tools-mainnet + REGION: us-central1 + REPO: nexus-tools + CRATE: "{{crate_name}}" + SERVICE: "{{crate_name}}-mainnet" + +jobs: + guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Require testnet success on this commit + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const ref = context.sha; + const { data } = await github.rest.checks.listForRef({ + owner, repo, ref, check_name: 'build-deploy-register' + }); + const ok = data.check_runs.some(c => + c.name === 'build-deploy-register' && c.conclusion === 'success' + ); + if (!ok) { + core.setFailed('Testnet deploy must succeed on this commit before mainnet.'); + } + + build-deploy-register: + needs: guard + runs-on: ubuntu-latest + environment: mainnet # requires reviewer approval (configured in GitHub UI) + steps: + - uses: actions/checkout@v4 + + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER_MAINNET }} + service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker + run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet + + - name: Build and push image + env: + SHA: ${{ github.sha }} + run: | + IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" + docker build -f tools/${CRATE}/deploy/Dockerfile -t "$IMAGE" . + docker push "$IMAGE" + + - name: Deploy to Cloud Run + env: + SHA: ${{ github.sha }} + run: | + IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" + envsubst < tools/${CRATE}/deploy/cloud-run.mainnet.yaml > /tmp/svc.yaml + gcloud run services replace /tmp/svc.yaml --region "$REGION" --quiet + URL=$(gcloud run services describe "$SERVICE" --region "$REGION" --format='value(status.url)') + echo "URL=$URL" >> "$GITHUB_ENV" + + - name: Smoke test + run: | + for p in $(jq -r '.[]' tools/${CRATE}/paths.json); do + curl -fsS "${URL}${p}/health" + curl -fsS "${URL}${p}/meta" | jq -e .fqn >/dev/null + done + + - name: Install Nexus CLI + run: | + curl -fsSL https://raw.githubusercontent.com/Talus-Network/homebrew-tap/main/install.sh | bash + echo "$HOME/.nexus/bin" >> "$GITHUB_PATH" + + - name: Register / update tool on Nexus mainnet + run: bash tools/${CRATE}/deploy/register.sh "$URL" mainnet diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-testnet.yml.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-testnet.yml.tmpl new file mode 100644 index 0000000..3f3b403 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-testnet.yml.tmpl @@ -0,0 +1,70 @@ +name: deploy-{{crate_name}}-testnet + +on: + push: + branches: [main] + paths: + - "tools/{{crate_name}}/**" + - ".github/workflows/deploy-{{crate_name}}-testnet.yml" + +permissions: + contents: read + id-token: write # for Workload Identity Federation + +env: + PROJECT_ID: talus-tools-testnet + REGION: us-central1 + REPO: nexus-tools + CRATE: "{{crate_name}}" + SERVICE: "{{crate_name}}-testnet" + +jobs: + build-deploy-register: + runs-on: ubuntu-latest + environment: testnet + steps: + - uses: actions/checkout@v4 + + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER_TESTNET }} + service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker + run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet + + - name: Build and push image + env: + SHA: ${{ github.sha }} + run: | + IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" + docker build -f tools/${CRATE}/deploy/Dockerfile -t "$IMAGE" . + docker push "$IMAGE" + + - name: Deploy to Cloud Run + env: + SHA: ${{ github.sha }} + run: | + IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" + envsubst < tools/${CRATE}/deploy/cloud-run.testnet.yaml > /tmp/svc.yaml + gcloud run services replace /tmp/svc.yaml --region "$REGION" --quiet + URL=$(gcloud run services describe "$SERVICE" --region "$REGION" --format='value(status.url)') + echo "URL=$URL" >> "$GITHUB_ENV" + + - name: Smoke test + run: | + for p in $(jq -r '.[]' tools/${CRATE}/paths.json); do + curl -fsS "${URL}${p}/health" + curl -fsS "${URL}${p}/meta" | jq -e .fqn >/dev/null + done + + - name: Install Nexus CLI + run: | + curl -fsSL https://raw.githubusercontent.com/Talus-Network/homebrew-tap/main/install.sh | bash + echo "$HOME/.nexus/bin" >> "$GITHUB_PATH" + + - name: Register / update tool on Nexus testnet + run: bash tools/${CRATE}/deploy/register.sh "$URL" testnet diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl new file mode 100644 index 0000000..088607b --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Idempotent registration of {{crate_name}} with Nexus. +# +# Usage: +# register.sh +# +# Reads tool FQNs from `${URL}/meta` (one per path defined in the crate), +# then registers or updates each FQN against Nexus on the chosen network. + +set -euo pipefail + +URL="${1:?missing first arg: tool URL}" +NETWORK="${2:?missing second arg: testnet|mainnet}" + +case "$NETWORK" in + testnet|mainnet) ;; + *) echo "network must be 'testnet' or 'mainnet'"; exit 2 ;; +esac + +# Discover every NexusTool path the binary serves. +# `bootstrap!` mounts //meta for each — but we don't know paths a priori. +# Convention: the skill generates a `tools//paths.json` array of paths, +# emitted at scaffold time. +PATHS_FILE="$(dirname "$0")/../paths.json" +if [[ ! -f "$PATHS_FILE" ]]; then + echo "expected $PATHS_FILE to enumerate tool paths" + exit 2 +fi + +mapfile -t PATHS < <(jq -r '.[]' "$PATHS_FILE") + +for path in "${PATHS[@]}"; do + meta_url="${URL%/}${path}/meta" + meta="$(curl -fsS "$meta_url")" + fqn="$(echo "$meta" | jq -r .fqn)" + description="$(echo "$meta" | jq -r .description)" + + echo "registering $fqn at $URL$path on $NETWORK" + + if nexus tool list --network "$NETWORK" | grep -q "$fqn"; then + nexus tool update offchain \ + --network "$NETWORK" \ + --tool-fqn "$fqn" \ + --url "${URL%/}${path}" + else + nexus tool register offchain \ + --network "$NETWORK" \ + --tool-fqn "$fqn" \ + --url "${URL%/}${path}" \ + --description "$description" + fi +done diff --git a/.claude/skills/nexus-tool-builder/templates/move/Move.toml.tmpl b/.claude/skills/nexus-tool-builder/templates/move/Move.toml.tmpl new file mode 100644 index 0000000..5508cdd --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/move/Move.toml.tmpl @@ -0,0 +1,16 @@ +[package] +name = "{{move_package_name}}" +edition = "2024.beta" + +[dependencies] +nexus_primitives = { local = "path/to/nexus/primitives" } +nexus_workflow = { local = "path/to/nexus/workflow" } +nexus_interface = { local = "path/to/nexus/interface" } + +[addresses] +{{move_package_name}} = "0x0" +nexus_primitives = "0xf311c80e60f77ba6008237ed0cd619a05f25894cdac3e2318cf41c74e8e24cea" +nexus_workflow = "0x5addaf9046d4a16ec3cbbe4fa9f89b5da73e0304ed1fff26fb8301574692cf4b" +nexus_interface = "0xf66be6face6f35dea9d6aea2b6ce8930a2bf7f55ac9ec7c80ffcb8182cabf5ae" + +# Update local paths and on-chain addresses for your target network. diff --git a/.claude/skills/nexus-tool-builder/templates/move/sources/tool.move.tmpl b/.claude/skills/nexus-tool-builder/templates/move/sources/tool.move.tmpl new file mode 100644 index 0000000..c795446 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/move/sources/tool.move.tmpl @@ -0,0 +1,85 @@ +module {{move_package_name}}::{{move_package_name}}; + +use nexus_primitives::data; +use nexus_primitives::proof_of_uid::ProofOfUID; +use nexus_primitives::tagged_output::{Self, TaggedOutput}; +use sui::bag::{Self, Bag}; +use sui::clock::Clock; +use sui::transfer::share_object; +use std::ascii::String as AsciiString; + +/// One-time witness for package initialization. +public struct {{MOVE_PACKAGE_NAME_UPPER}} has drop {} + +/// Witness object used to identify this tool when calling +/// `nexus tool register onchain --witness-id …`. +public struct {{ToolPascal}}Witness has key, store { id: UID } + +/// Shared mutable state passed into `execute`. +public struct {{ToolPascal}}State has key { + id: UID, + witness: Bag, + // Application-specific fields go here. +} + +public enum Output { + Ok { + result: u64, + }, + Err { + reason: AsciiString, + }, + // Additional variants as needed. Variant names starting with `err` are + // treated by Nexus as error variants. +} + +fun init(_otw: {{MOVE_PACKAGE_NAME_UPPER}}, ctx: &mut TxContext) { + let state = {{ToolPascal}}State { + id: object::new(ctx), + witness: { + let mut bag = bag::new(ctx); + bag.add(b"witness", {{ToolPascal}}Witness { id: object::new(ctx) }); + bag + }, + }; + share_object(state); +} + +/// CRITICAL REQUIREMENTS (enforced by the Nexus runtime): +/// 1. First parameter: `worksheet: &mut ProofOfUID`. +/// 2. Last parameter: `ctx: &mut TxContext`. +/// 3. Return type: `TaggedOutput`. +/// 4. The worksheet must be stamped with the witness ID before returning. +public fun execute( + worksheet: &mut ProofOfUID, + state: &mut {{ToolPascal}}State, + input_value: u64, + _clock: &Clock, + _ctx: &mut TxContext, +): TaggedOutput { + let witness = state.witness(); + worksheet.stamp_with_data(&witness.id, b"{{move_package_name}}_executed"); + + if (input_value == 0) { + tagged_output::new(b"err") + .with_named_payload(b"reason", data::inline_one(b"Input value cannot be zero").as_string()) + } else { + let result = input_value * 2; + tagged_output::new(b"ok") + .with_named_payload(b"result", data::inline_one(result.to_string().into_bytes()).as_number()) + } +} + +fun witness(self: &{{ToolPascal}}State): &{{ToolPascal}}Witness { + self.witness.borrow(b"witness") +} + +/// Read the witness object id (used when registering the tool with Nexus). +public fun witness_id(self: &{{ToolPascal}}State): ID { + self.witness().id.to_inner() +} + +#[test_only] +public fun init_for_test(otw: {{MOVE_PACKAGE_NAME_UPPER}}, ctx: &mut TxContext) { + init(otw, ctx); +} diff --git a/.claude/skills/nexus-tool-builder/templates/rust/Cargo.toml.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/Cargo.toml.tmpl new file mode 100644 index 0000000..c0a0195 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/rust/Cargo.toml.tmpl @@ -0,0 +1,29 @@ +[package] +name = "{{crate_name}}" +description = "{{crate_description}}" + +edition.workspace = true +version.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +readme.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +chrono.workspace = true +thiserror.workspace = true +tokio.workspace = true +reqwest = { workspace = true, features = ["json"] } +serde_json.workspace = true +serde.workspace = true +schemars.workspace = true + +# === Nexus deps === +nexus-toolkit.workspace = true +nexus-sdk.workspace = true + +[dev-dependencies] +mockito.workspace = true diff --git a/.claude/skills/nexus-tool-builder/templates/rust/README.md.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/README.md.tmpl new file mode 100644 index 0000000..81358a8 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/rust/README.md.tmpl @@ -0,0 +1,28 @@ +# {{ServicePascal}} tools for Nexus + +This crate exposes {{ServicePascal}} API endpoints as Nexus Tools. +API reference: [{{api_docs_url}}]({{api_docs_url}}). + +--- + +# `{{fqn}}` + +{{endpoint_description}} + +## Input + +**`{{example_input_name}}`: [`String`]** — {{example_input_doc}}. + +## Output Variants & Ports + +**`ok`** — The request succeeded. + +- **`ok.{{example_output_name}}`: [`String`]** — {{example_output_doc}}. + +**`err`** — The request failed. + +- **`err.reason`: [`String`]** — A detailed error message. +- **`err.kind`: [`String`]** — Type of error (invalid_request, not_found, parse, etc.). +- **`err.status_code`: [`u16`] (optional)** — HTTP status code if available. + + diff --git a/.claude/skills/nexus-tool-builder/templates/rust/client.rs.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/client.rs.tmpl new file mode 100644 index 0000000..e6fe753 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/rust/client.rs.tmpl @@ -0,0 +1,184 @@ +//! {{ServicePascal}} API client. +//! +//! Wraps `reqwest::Client` with `.get` / `.post` / `.post_form` +//! helpers that return a typed `{{ServicePascal}}ErrorResponse` on failure. +//! +//! ## Credential model +//! +//! **This client holds NO upstream API credentials.** Per the +//! nexus-tools repo convention (see +//! `tools/llm-openai-chat-completion`, `tools/social-twitter`), every +//! credential is passed in by the caller on each request, sourced from +//! the `Input` struct of the invoking `NexusTool`. +//! +//! The endpoint code calls `client.with_auth(&input.api_key)` (or +//! equivalent) before each request — never `std::env::var(...)`. +//! +//! Auth model selected for this crate: {{auth_model}}. + +use { + crate::{ + error::{ {{ServicePascal}}ErrorKind, {{ServicePascal}}ErrorResponse }, + tools::{{SERVICE_UPPER}}_API_BASE, + }, + reqwest::{Client, RequestBuilder}, + serde::{de::DeserializeOwned, Serialize}, + std::sync::Arc, +}; + +#[derive(Clone)] +pub struct {{ServicePascal}}Client { + client: Arc, + base_url: String, + /// Optional per-call bearer credential. Set via `.with_auth(...)`. + /// Held only for the duration of one invocation; never persisted. + bearer: Option, + /// Optional per-call idempotency key. Set via `.with_idempotency(...)`. + idempotency_key: Option, +} + +impl {{ServicePascal}}Client { + pub fn new(base_url: Option<&str>) -> Self { + let base_url = base_url.unwrap_or({{SERVICE_UPPER}}_API_BASE).to_string(); + + let client = Client::builder() + .user_agent("nexus-sdk-{{crate_name}}/1.0") + .build() + .expect("Failed to create HTTP client"); + + Self { + client: Arc::new(client), + base_url, + bearer: None, + idempotency_key: None, + } + } + + /// Attach a bearer credential for the next request. The credential is + /// passed in by the DAG via the `Input` struct — this client never + /// reads it from env / disk. + #[must_use] + pub fn with_auth(mut self, bearer: &str) -> Self { + self.bearer = Some(bearer.to_string()); + self + } + + /// Attach an `Idempotency-Key` header for the next request. Required + /// by Stripe-style APIs on writes; harmless on others. + #[must_use] + pub fn with_idempotency(mut self, key: &str) -> Self { + self.idempotency_key = Some(key.to_string()); + self + } + + /// GET — JSON response. + pub async fn get(&self, endpoint: &str) -> Result + where + T: DeserializeOwned, + { + let url = self.url(endpoint); + let req = self.apply_headers(self.client.get(&url)); + self.send(req).await + } + + /// POST with a JSON body. + pub async fn post(&self, endpoint: &str, body: &B) -> Result + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + let url = self.url(endpoint); + let req = self.apply_headers(self.client.post(&url).json(body)); + self.send(req).await + } + + /// POST with a form-encoded body (Stripe / OAuth-style APIs). + pub async fn post_form(&self, endpoint: &str, body: &B) -> Result + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + let url = self.url(endpoint); + let req = self.apply_headers(self.client.post(&url).form(body)); + self.send(req).await + } + + /// DELETE. + pub async fn delete(&self, endpoint: &str) -> Result + where + T: DeserializeOwned, + { + let url = self.url(endpoint); + let req = self.apply_headers(self.client.delete(&url)); + self.send(req).await + } + + fn url(&self, endpoint: &str) -> String { + format!("{}/{}", self.base_url.trim_end_matches('/'), endpoint.trim_start_matches('/')) + } + + fn apply_headers(&self, mut req: RequestBuilder) -> RequestBuilder { + if let Some(ref bearer) = self.bearer { + req = req.bearer_auth(bearer); + } + if let Some(ref key) = self.idempotency_key { + req = req.header("Idempotency-Key", key); + } + req + } + + async fn send(&self, req: RequestBuilder) -> Result + where + T: DeserializeOwned, + { + let response = match req.send().await { + Ok(r) => r, + Err(e) => { + return Err({{ServicePascal}}ErrorResponse { + reason: format!("Network error: {}", e), + kind: {{ServicePascal}}ErrorKind::from_network_error(&e), + status_code: Some(0), + }) + } + }; + + let status = response.status(); + let text = match response.text().await { + Ok(t) => t, + Err(e) => { + return Err({{ServicePascal}}ErrorResponse { + reason: format!("Failed to read response: {}", e), + kind: {{ServicePascal}}ErrorKind::Parse, + status_code: None, + }) + } + }; + + if !status.is_success() { + // Try to extract a structured error per the API's envelope. + // The error.rs template provides a try_parse_api_error helper. + if let Some(parsed) = crate::error::try_parse_api_error(&text, status.as_u16()) { + return Err(parsed); + } + return Err({{ServicePascal}}ErrorResponse { + reason: format!("API error ({}): {}", status, truncate(&text, 512)), + kind: {{ServicePascal}}ErrorKind::from_status_code(status.as_u16()), + status_code: Some(status.as_u16()), + }); + } + + serde_json::from_str::(&text).map_err(|e| {{ServicePascal}}ErrorResponse { + reason: format!("Failed to parse JSON: {}", e), + kind: {{ServicePascal}}ErrorKind::Parse, + status_code: None, + }) + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}…", &s[..max]) + } +} diff --git a/.claude/skills/nexus-tool-builder/templates/rust/endpoint.rs.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/endpoint.rs.tmpl new file mode 100644 index 0000000..dee4a5e --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/rust/endpoint.rs.tmpl @@ -0,0 +1,197 @@ +//! # `{{fqn}}` +//! +//! {{endpoint_description}} +//! +//! ## Statelessness contract +//! +//! Per the `nexus-toolkit` contract, `new()` is called on every request. +//! This tool MUST be stateless across invocations: +//! +//! - No `static` / `lazy_static` / `OnceLock` storing per-request data. +//! - No `Mutex` / `RwLock` / `Cell` / `RefCell` accumulating state. +//! - No on-disk writes outside `#[cfg(test)]`. +//! - The struct's only fields are configuration / clones of a stateless +//! `reqwest::Client` pool. Same input + same `Input.api_key` ⇒ +//! identical upstream call. +//! +//! ## Credential contract +//! +//! Upstream API credentials live ONLY in the `Input` struct, passed by +//! the DAG / Leader per invocation. This tool never reads credentials +//! from env vars, files, or any persistent store. + +use { + crate::{ + {{service_snake}}_client::{{ServicePascal}}Client, + error::{{ServicePascal}}ErrorKind, + }, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + /// {{ServicePascal}} secret key passed in by the DAG / Leader. + /// NEVER set this as a `default_values` entry in the on-chain DAG — + /// values committed to Sui are public and permanent. The Leader + /// supplies this at execution time from its secret store. + pub api_key: String, + + {{#if is_write}} + /// Optional `Idempotency-Key` header. Generate a UUID per logical + /// retry-bucket in your DAG; reusing the same key returns the same + /// upstream response without double-charging. + pub idempotency_key: Option, + + {{/if}} + // TODO: replace with the real input ports. + // + // Style rules: + // - snake_case + // - optional fields use Option + // - split paired fields (prompt + context) into separate ports + // - never derive Debug on Input — it would log api_key. Use a + // manual Debug impl that redacts credential fields if needed. + /// {{example_input_doc}} + pub {{example_input_name}}: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + // TODO: flat output ports. Crucial fields must NOT be Option. + /// {{example_output_doc}} + {{example_output_name}}: String, + }, + Err { + reason: String, + kind: {{ServicePascal}}ErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +/// Stateless: holds only a stateless connection pool. `new()` may be +/// called per request without performance penalty. +pub(crate) struct {{EndpointPascal}} { + client: {{ServicePascal}}Client, +} + +impl NexusTool for {{EndpointPascal}} { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: {{ServicePascal}}Client::new(None), + } + } + + fn fqn() -> ToolFqn { + fqn!("{{fqn}}") + } + + fn path() -> &'static str { + "{{path}}" + } + + fn description() -> &'static str { + "{{endpoint_description}}" + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + // Build a per-call authed client; the credential lives only in + // this function's scope and goes out of scope when invoke returns. + let client = self.client.clone().with_auth(&input.api_key); + + {{#if is_write}} + let client = match input.idempotency_key { + Some(ref k) => client.with_idempotency(k), + None => client, + }; + {{/if}} + + let endpoint = format!("{{endpoint_path_template}}", input.{{example_input_name}}); + + match client.get::(&endpoint).await { + Ok(_value) => Output::Ok { + {{example_output_name}}: "TODO: map response field".to_string(), + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, {{EndpointPascal}}) { + let server = Server::new_async().await; + let client = {{ServicePascal}}Client::new(Some(&server.url())); + let tool = {{EndpointPascal}} { client }; + (server, tool) + } + + fn test_input({{example_input_name}}: &str) -> Input { + Input { + // Test-mode key only — fixtures must never contain `sk_live_…`. + api_key: "sk_test_FAKE_FOR_TESTS_ONLY".to_string(), + {{#if is_write}} + idempotency_key: None, + {{/if}} + {{example_input_name}}: {{example_input_name}}.to_string(), + } + } + + #[tokio::test] + async fn test_success() { + let (mut server, tool) = create_server_and_tool().await; + + let mock = server + .mock("GET", "{{mock_path}}") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "{{example_output_name}}": "value" }).to_string()) + .create_async() + .await; + + let result = tool.invoke(test_input("test")).await; + match result { + Output::Ok { .. } => { /* assert specific fields */ } + Output::Err { reason, .. } => panic!("Expected Ok, got Err: {}", reason), + } + mock.assert_async().await; + } + + #[tokio::test] + async fn test_api_error() { + let (mut server, tool) = create_server_and_tool().await; + + let _mock = server + .mock("GET", "{{mock_path}}") + .with_status(404) + .with_header("content-type", "application/json") + .with_body(json!({ "error": { "type": "invalid_request_error", "message": "not found" } }).to_string()) + .create_async() + .await; + + let result = tool.invoke(test_input("test")).await; + assert!(matches!(result, Output::Err { kind: {{ServicePascal}}ErrorKind::InvalidRequest, .. })); + } +} diff --git a/.claude/skills/nexus-tool-builder/templates/rust/error.rs.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/error.rs.tmpl new file mode 100644 index 0000000..fb73c79 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/rust/error.rs.tmpl @@ -0,0 +1,162 @@ +use { + reqwest::StatusCode, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, + thiserror::Error, +}; + +/// Machine-readable error kinds for {{ServicePascal}} operations. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum {{ServicePascal}}ErrorKind { + /// HTTP 400 — client-side input was rejected. + InvalidRequest, + /// Credit-card-style error (Stripe `card_error`). + CardError, + /// Validation error returned by the API (Stripe `validation_error`). + ValidationError, + /// Idempotency conflict (Stripe `idempotency_error`). + IdempotencyError, + /// HTTP 401 — missing or invalid credentials. + Unauthorized, + /// HTTP 402 — payment required. + PaymentRequired, + /// HTTP 403 — credentials lack permission for this resource. + Forbidden, + /// HTTP 404 — resource not found. + NotFound, + /// HTTP 408 / network timeout. + TimedOut, + /// HTTP 409 — conflict (duplicate, version skew). + Conflict, + /// HTTP 429 — rate limit exceeded. + RateLimitExceeded, + /// HTTP 500 — upstream server error. + InternalServerError, + /// HTTP 502 — upstream returned a bad gateway response. + BadGateway, + /// HTTP 503 — upstream service unavailable. + ServiceUnavailable, + /// Network connection failed. + NetworkConnectionFailed, + /// Failed to parse the upstream response. + Parse, + /// Catch-all for unmapped errors. + Unknown, +} + +/// Standard error response surface for {{ServicePascal}} tools. +#[derive(Debug, Serialize, Deserialize)] +pub struct {{ServicePascal}}ErrorResponse { + pub reason: String, + pub kind: {{ServicePascal}}ErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_code: Option, +} + +#[derive(Error, Debug)] +#[allow(dead_code)] +pub enum {{ServicePascal}}Error { + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + #[error("Response parsing error: {0}")] + ParseError(#[from] serde_json::Error), + + #[error("API error: {0}")] + ApiError(String), + + #[error("API status error: {0}")] + StatusError(StatusCode), +} + +impl {{ServicePascal}}ErrorKind { + pub fn from_status_code(status_code: u16) -> Self { + match status_code { + 400 => Self::InvalidRequest, + 401 => Self::Unauthorized, + 402 => Self::PaymentRequired, + 403 => Self::Forbidden, + 404 => Self::NotFound, + 408 => Self::TimedOut, + 409 => Self::Conflict, + 429 => Self::RateLimitExceeded, + 500 => Self::InternalServerError, + 502 => Self::BadGateway, + 503 => Self::ServiceUnavailable, + 504 => Self::TimedOut, + _ => Self::Unknown, + } + } + + pub fn from_network_error(error: &reqwest::Error) -> Self { + if error.is_timeout() { + Self::TimedOut + } else if error.is_connect() { + Self::NetworkConnectionFailed + } else { + Self::NetworkConnectionFailed + } + } + + /// Map a provider-specific error type string to our kind. Override + /// per service; defaults below cover the Stripe envelope. + pub fn from_api_error_type(error_type: &str) -> Self { + match error_type { + // Stripe-style error types + "card_error" => Self::CardError, + "validation_error" => Self::ValidationError, + "invalid_request_error" => Self::InvalidRequest, + "idempotency_error" => Self::IdempotencyError, + "rate_limit_error" => Self::RateLimitExceeded, + "authentication_error" => Self::Unauthorized, + "api_error" | "api_connection_error" => Self::InternalServerError, + // Generic fallbacks + "invalid_request" => Self::InvalidRequest, + "not_found" => Self::NotFound, + "unauthorized" => Self::Unauthorized, + "forbidden" => Self::Forbidden, + _ => Self::Unknown, + } + } +} + +/// Per-service envelope shape — Stripe's `{"error": {...}}` is the +/// default; other services override. +#[derive(Debug, Deserialize)] +struct StripeStyleEnvelope { + error: StripeStyleError, +} + +#[derive(Debug, Deserialize)] +struct StripeStyleError { + #[serde(rename = "type")] + error_type: Option, + code: Option, + message: Option, + param: Option, +} + +/// Best-effort parse of a non-2xx response body into a typed error. +/// Returns `None` if the body shape isn't recognized; the caller falls +/// back to a generic mapping. +pub fn try_parse_api_error(body: &str, status_code: u16) -> Option<{{ServicePascal}}ErrorResponse> { + if let Ok(env) = serde_json::from_str::(body) { + let kind = env.error.error_type + .as_deref() + .map(|t| {{ServicePascal}}ErrorKind::from_api_error_type(t)) + .unwrap_or_else(|| {{ServicePascal}}ErrorKind::from_status_code(status_code)); + let reason = match (env.error.message, env.error.code, env.error.param) { + (Some(m), _, Some(p)) => format!("{} (param: {})", m, p), + (Some(m), _, None) => m, + (None, Some(c), _) => format!("API error code: {}", c), + _ => format!("API error ({})", status_code), + }; + return Some({{ServicePascal}}ErrorResponse { + reason, + kind, + status_code: Some(status_code), + }); + } + None +} diff --git a/.claude/skills/nexus-tool-builder/templates/rust/main.rs.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/main.rs.tmpl new file mode 100644 index 0000000..b2b7be5 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/rust/main.rs.tmpl @@ -0,0 +1,16 @@ +#![doc = include_str!("../README.md")] +#![allow(clippy::large_enum_variant)] + +use nexus_toolkit::bootstrap; + +mod {{service_snake}}_client; +mod error; +mod tools; + +#[tokio::main] +async fn main() { + bootstrap!([ + // Add one entry per endpoint, e.g.: + // tools::{{endpoint_snake}}::{{EndpointPascal}}, + ]); +} diff --git a/.claude/skills/nexus-tool-builder/templates/rust/models.rs.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/models.rs.tmpl new file mode 100644 index 0000000..c7f7348 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/rust/models.rs.tmpl @@ -0,0 +1,21 @@ +//! Shared {{ServicePascal}} response models used by two or more endpoints. + +use { + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +/// Generic envelope shape — adapt to the upstream API's actual response. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub(crate) struct {{ServicePascal}}ApiResponse { + pub data: Option, + pub errors: Option>, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub(crate) struct {{ServicePascal}}ApiErrorItem { + #[serde(rename = "message")] + pub message: Option, + #[serde(rename = "type")] + pub error_type: Option, +} diff --git a/.claude/skills/nexus-tool-builder/templates/rust/tools_mod.rs.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/tools_mod.rs.tmpl new file mode 100644 index 0000000..1277ff6 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/rust/tools_mod.rs.tmpl @@ -0,0 +1,22 @@ +//! {{ServicePascal}} endpoints. +//! +//! Each module under here is one stateless `NexusTool`. Shared state is +//! prohibited — there must be no `static` accumulator, no +//! `lazy_static`, no `Mutex`/`RwLock` carrying request data across +//! invocations. Configuration constants (URLs, version strings) are +//! fine. + +pub(crate) const {{SERVICE_UPPER}}_API_BASE: &str = "{{api_base_url}}"; + +// One `pub(crate) mod ;` per endpoint file: +// pub(crate) mod {{endpoint_snake}}; + +pub(crate) mod models; + +// Shared custom deserializers (if any) go here. Functions only — no +// mutable shared state. +// +// pub(crate) fn deserialize_thing<'de, D>(deserializer: D) -> Result +// where +// D: serde::Deserializer<'de>, +// { /* ... */ } From d522ab89791c6e91987b275f9703f8546c8a3b54 Mon Sep 17 00:00:00 2001 From: Mike Hanono Date: Fri, 15 May 2026 18:35:13 +0000 Subject: [PATCH 2/3] Fix pre-commit hook failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chmod +x .claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl (12-executable-scripts: any file with a shebang on line 1 must be executable in the repo) - Convert numbered list items to all `1.` in SKILL.md, hosting-options.md per `MD029` style "one" (markdownlint config sets this) - Add `text` language tag to three bare fenced code blocks (MD040) - Add blank lines around lists in nexus-tool-auditor.md (MD032) - Rephrase SKILL.md verify section to avoid a wrapped `+` that markdownlint interpreted as a bullet (MD004) Verified locally: npx markdownlint-cli2@0.18.1 '.claude/**/*.md' --config .markdownlint.json Summary: 0 error(s) for f in $(git ls-files); do …shebang+exec check; done clean Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/agents/nexus-tool-auditor.md | 4 +- .claude/skills/nexus-tool-builder/SKILL.md | 38 +++++++++---------- .../reference/architecture.md | 2 +- .../reference/hosting-options.md | 4 +- .../templates/deploy/register.sh.tmpl | 0 5 files changed, 25 insertions(+), 23 deletions(-) mode change 100644 => 100755 .claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl diff --git a/.claude/agents/nexus-tool-auditor.md b/.claude/agents/nexus-tool-auditor.md index 51278d3..0ecd353 100644 --- a/.claude/agents/nexus-tool-auditor.md +++ b/.claude/agents/nexus-tool-auditor.md @@ -64,6 +64,7 @@ bash .claude/skills/nexus-tool-builder/scripts/audit.sh off-chain ``` The script wraps: + - `cargo +stable check --all-targets` - `cargo +stable clippy --all-targets --all-features -- -D warnings -W clippy::pedantic` - `cargo +stable test --no-run` (does the test suite compile?) @@ -143,6 +144,7 @@ bash .claude/skills/nexus-tool-builder/scripts/audit.sh on-chain ``` The script wraps: + - `sui move build` - `sui move test` - `sui move prove` (Move Prover — best-effort; not all rules are @@ -294,7 +296,7 @@ Blockers (if any): After writing `AUDIT.md`, print a concise summary to the user: -``` +```text nexus-tool-auditor: @ CRITICAL: HIGH: MEDIUM: Recommendation: diff --git a/.claude/skills/nexus-tool-builder/SKILL.md b/.claude/skills/nexus-tool-builder/SKILL.md index eb22744..ac96486 100644 --- a/.claude/skills/nexus-tool-builder/SKILL.md +++ b/.claude/skills/nexus-tool-builder/SKILL.md @@ -39,11 +39,11 @@ Reference material lives in `reference/`; rendered file templates live in 1. Confirm the working repo is `nexus-tools` (or another repo that pulls `nexus-toolkit`). Read the workspace `Cargo.toml`; it should declare `nexus-toolkit` and `nexus-sdk`. -2. Confirm the `nexus` CLI is installed: `nexus --version`. If absent, point +1. Confirm the `nexus` CLI is installed: `nexus --version`. If absent, point the user at the install instructions in `Talus-Network/nexus-sdk/cli` before continuing. Do not silently fall back to local-only templates — staying on the official scaffold keeps tools aligned with upstream. -3. Read `reference/architecture.md` and `reference/style-guide.md` (or +1. Read `reference/architecture.md` and `reference/style-guide.md` (or `reference/onchain-tools.md` if the user requested an on-chain tool). ## Required inputs (collect via AskUserQuestion when ambiguous) @@ -63,11 +63,11 @@ Reference material lives in `reference/`; rendered file templates live in 1. **Discover endpoints.** Use WebFetch on the docs URL (and any linked sub-pages). Produce a table of `{ name, http_method, path, query/body params, response schema, error shape }`. Echo it back to the user. -2. **Design ports per endpoint** honoring the style guide: `snake_case` +1. **Design ports per endpoint** honoring the style guide: `snake_case` names, error variants prefixed `err`, flat outputs, crucial ports non-optional, split prompt/context-style fields the way the DAG needs them. -3. **Scaffold via the official CLI:** +1. **Scaffold via the official CLI:** ```sh bash .claude/skills/nexus-tool-builder/scripts/new_tool.sh \ @@ -78,18 +78,18 @@ Reference material lives in `reference/`; rendered file templates live in the generated crate into `tools/`, and renders the local templates over it. (Workspace `Cargo.toml` already declares `members = ["tools/*"]`, so the crate is picked up automatically.) -4. **Generate per-endpoint code** by rendering `templates/rust/endpoint.rs.tmpl` +1. **Generate per-endpoint code** by rendering `templates/rust/endpoint.rs.tmpl` once per endpoint into `tools//src/tools/.rs`. Each file contains `Input`, `Output` (enum with `Ok` / `Err`), an `impl NexusTool`, and a `mockito` test module. Add `pub(crate) mod ;` to `src/tools/mod.rs`. Append the tool to the `bootstrap!([…])` list in `src/main.rs`. -5. **Generate `README.md`** with one section per FQN matching +1. **Generate `README.md`** with one section per FQN matching `tools/exchanges-coinbase/README.md`. Include the API docs URL. -6. **Register the crate in the build:** append the new package name to each +1. **Register the crate in the build:** append the new package name to each `--package` line in `tools/.just` (recipes: `build`, `check`, `test`, `fmt-check`, `clippy`). -7. **Emit deploy scaffolding** into `tools//deploy/` and +1. **Emit deploy scaffolding** into `tools//deploy/` and `.github/workflows/`: - `Dockerfile` (multi-stage Rust → distroless, exposes 8080). - `cloud-run.testnet.yaml` and `cloud-run.mainnet.yaml` (service config @@ -99,19 +99,19 @@ Reference material lives in `reference/`; rendered file templates live in - `.github/workflows/deploy--testnet.yml` (push to `main`). - `.github/workflows/deploy--mainnet.yml` (tag `v-*`, gated on testnet green). -8. **Verify** (stop on first failure): +1. **Verify** (stop on first failure): ```sh bash .claude/skills/nexus-tool-builder/scripts/verify.sh ``` The script runs `cargo check`, `cargo clippy -- -D warnings`, - `cargo test`, `cargo fmt --check` (nightly), and a `cargo run` - + `/health` + `/meta` smoke test. + `cargo test`, `cargo fmt --check` (nightly), and a `cargo run` plus + `/health` and `/meta` smoke test. -9. **Audit** by invoking the `nexus-tool-auditor` sub-agent: +1. **Audit** by invoking the `nexus-tool-auditor` sub-agent: - ``` + ```text Agent({ subagent_type: "nexus-tool-auditor", description: "Security + conformance audit of ", @@ -125,7 +125,7 @@ Reference material lives in `reference/`; rendered file templates live in Refuse to mark the tool ready if any CRITICAL findings are open. For testnet, HIGH findings can be filed as follow-up issues. -10. **Hand off** with the new FQN(s), the path `tools//`, +1. **Hand off** with the new FQN(s), the path `tools//`, `just tools run `, the deploy URLs the workflows will create, the path to `tools//AUDIT.md`, and a reminder to set `NEXUS_TOOLKIT_CONFIG_PATH` and `signed_http.mode = "required"` before @@ -142,19 +142,19 @@ Reference material lives in `reference/`; rendered file templates live in ``` Wraps `nexus tool new --name _onchain --template move`. -2. **Generate** the Move module from +1. **Generate** the Move module from `templates/move/sources/tool.move.tmpl` following `reference/onchain-tools.md`: - `execute(worksheet: &mut ProofOfUID, …, ctx: &mut TxContext) -> TaggedOutput` - `Output` enum with `Ok` / `Err` variants - witness object + `witness_id()` getter -3. **Test:** `sui move test`. -4. **Audit** by invoking the `nexus-tool-auditor` sub-agent with +1. **Test:** `sui move test`. +1. **Audit** by invoking the `nexus-tool-auditor` sub-agent with `kind=on-chain` — on-chain tools have the highest blast radius (witness bypass, missing authorization, gas grief). Refuse to print the publish commands if any CRITICAL findings are open: - ``` + ```text Agent({ subagent_type: "nexus-tool-auditor", description: "On-chain security audit of _onchain", @@ -165,7 +165,7 @@ Reference material lives in `reference/`; rendered file templates live in }) ``` -5. **Print** (don't run) the publish + register commands for the user to +1. **Print** (don't run) the publish + register commands for the user to execute themselves with their own keys: ```sh diff --git a/.claude/skills/nexus-tool-builder/reference/architecture.md b/.claude/skills/nexus-tool-builder/reference/architecture.md index 604b9df..fbcac13 100644 --- a/.claude/skills/nexus-tool-builder/reference/architecture.md +++ b/.claude/skills/nexus-tool-builder/reference/architecture.md @@ -4,7 +4,7 @@ Every off-chain tool crate in `tools/-/` follows the layout below. The canonical example is `tools/exchanges-coinbase` — read it when in doubt. -``` +```text tools/-/ ├── Cargo.toml # workspace inheritance; toolkit + sdk + reqwest + schemars + serde + mockito ├── README.md # one section per FQN — Input ports, Output Variants & Ports, docs link diff --git a/.claude/skills/nexus-tool-builder/reference/hosting-options.md b/.claude/skills/nexus-tool-builder/reference/hosting-options.md index 7bcb461..0b92aa3 100644 --- a/.claude/skills/nexus-tool-builder/reference/hosting-options.md +++ b/.claude/skills/nexus-tool-builder/reference/hosting-options.md @@ -37,8 +37,8 @@ The `Dockerfile` the skill emits is portable. To move a single tool to Akash or Spheron later: 1. Push the existing image to a public registry (Docker Hub / GHCR). -2. Write a small SDL (Akash) or Spheron config that references the image. -3. Run `nexus tool register offchain --tool-fqn --url ` to +1. Write a small SDL (Akash) or Spheron config that references the image. +1. Run `nexus tool register offchain --tool-fqn --url ` to point Nexus at the new URL. Idempotent — the FQN stays the same. No Rust code changes required. diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl old mode 100644 new mode 100755 From bc4451d8d3d12c6eac633a14da6236396266e37e Mon Sep 17 00:00:00 2001 From: Mike Hanono Date: Mon, 25 May 2026 18:46:47 +0000 Subject: [PATCH 3/3] Update nexus-tool-builder skill for post-#33 pipeline + env-var creds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realigns the skill, its templates, scripts, and the nexus-tool-auditor agent with two changes that landed on `main` after PR #31 was opened: 1. PR #33 restructured the repo: tools/ → offchain/tools/, single shared offchain/Dockerfile, five reusable workflows (.github/workflows/offchain-tools.{discover,prepare,deploy,register,readiness}.yml) that key off each crate's tools.json + build.rs. No per-tool Dockerfile, Cloud Run YAML, register.sh, or workflow file needed anymore. 2. PR #27/#28 (storage-walrus) and PR #29 (memory-memwal) established the env-var credential pattern: upstream API secrets are read once at startup via from_env(), wrapped in zeroize::Zeroizing, with a hand-written Debug that prints ``. NEVER put credentials on Input — tool inputs flow through the Nexus DAG on Sui as plaintext. Skill changes: - templates/deploy/ — DELETED (whole dir). Per-tool Dockerfile / Cloud Run YAML / GitHub workflow / register.sh / allowed_leaders.json are superseded by the shared offchain/Dockerfile + offchain-tools.* workflows. - templates/rust/tools.json.tmpl + build.rs.tmpl + env.example.tmpl — NEW. build.rs.tmpl is a verbatim copy of offchain/tools/memory-memwal/build.rs (validates [[bin]].name == tools.json.command, threads TOOL_FQN_VERSION via the Docker build-arg). - templates/rust/client.rs.tmpl — flipped from per-call .with_auth() to startup-env from_env(): Zeroizing bearer, hand-written Debug, shared HTTP pool via OnceLock, #[cfg(test)] for_testing() constructor. - templates/rust/endpoint.rs.tmpl — dropped api_key from Input; clients are authed once at startup; FQNs threaded through env!("TOOL_FQN_VERSION"). - templates/rust/main.rs.tmpl — adopted memwal's pattern: explicit multi-thread tokio runtime, env_logger init, --meta short-circuit (so CI's prepare step can pull /meta from an image with no env), load_dotenv_if_present() + validate_credentials_at_startup() before bootstrap!. - templates/rust/Cargo.toml.tmpl — added [[bin]], [build-dependencies], workspace deps for dotenvy / env_logger / log / anyhow / zeroize. - scripts/new_tool.sh — scaffolds at offchain/tools//, drops in tools.json + build.rs from templates, no more deploy/ dir. - scripts/verify.sh — discovers paths via /meta (no paths.json), runs cargo against the offchain workspace, boots binary with a fake env var so validate_credentials_at_startup passes. - scripts/audit.sh — new CRITICAL check `static:input-credential` (C1) refuses crates whose Input struct contains credential-shaped fields (api_key, *_secret, *_token, bearer*, password, private_key, access_token, consumer_key, consumer_secret, client_secret — minus a whitelist for idempotency_key, pagination_token, etc.). Added shape checks for tools.json + build.rs presence, FQN-versioning check, and legacy-plumbing detector. Paths updated to offchain/. Reference + agent updates: - reference/security-checklist.md — promoted no-secrets-in-Input to C1. Renumbered everything else. Rewrote C7 to require startup-env reads. Updated C9 statefulness rule to permit startup-built Arc-wrapped client state. - reference/architecture.md — rewrote layout diagram; added tools.json + build.rs siblings; deploy/ removed; memwal as canonical example. - reference/faq.md — dropped tools/.just entry; added entries for tools.json, BLOCKED_TOOLS, secret-mounting model, FQN versioning via TOOL_FQN_VERSION. - reference/hosting-options.md — clarified that the skill emits no per-tool deploy plumbing; documented the secret-naming convention (nexus-tools--v-…) and the operator-owned upstream-key secretKeyRef binding. - reference/style-guide.md — updated the `api_key` example (now a forbidden Input field; replaced with `payment_intent_id`). - reference/interface.md, reference/onchain-tools.md — no semantic changes (table-separator format only). - SKILL.md — rewrote workflow to reference offchain/tools/, tools.json + build.rs, no deploy artifacts emitted, env-var credential rule. Hard rules section: no credentials in Input (C1) at top. - agents/nexus-tool-auditor.md — added the hard rule: any credential-shaped Input field is an automatic CRITICAL that blocks ready-for-testnet. Paths updated to offchain/tools/. - .markdownlint config: no change; fixed compact-table separators in the touched markdown files (MD060). Verified locally: - bash .claude/skills/nexus-tool-builder/scripts/audit.sh off-chain social-twitter → FAIL static:input-credential (catches bearer_token on Input across 5 files) - bash .claude/skills/nexus-tool-builder/scripts/audit.sh off-chain memory-memwal → PASS static:input-credential (clean Input, env-var pattern) - npx markdownlint-cli2 '**/*.md' '!.git/' --config .markdownlint.json → 0 errors across 27 files Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/agents/nexus-tool-auditor.md | 68 +++++- .claude/skills/nexus-tool-builder/SKILL.md | 157 +++++++------ .../reference/architecture.md | 170 ++++++++++++--- .../nexus-tool-builder/reference/faq.md | 111 ++++++++-- .../reference/hosting-options.md | 45 ++-- .../nexus-tool-builder/reference/interface.md | 8 +- .../reference/security-checklist.md | 50 +++-- .../reference/style-guide.md | 6 +- .../nexus-tool-builder/scripts/audit.sh | 206 +++++++++++++----- .../nexus-tool-builder/scripts/new_tool.sh | 45 +++- .../nexus-tool-builder/scripts/verify.sh | 58 ++++- .../templates/deploy/Dockerfile.tmpl | 27 --- .../deploy/allowed_leaders.json.tmpl | 9 - .../deploy/cloud-run.mainnet.yaml.tmpl | 61 ------ .../deploy/cloud-run.testnet.yaml.tmpl | 61 ------ .../deploy/github-workflow-mainnet.yml.tmpl | 89 -------- .../deploy/github-workflow-testnet.yml.tmpl | 70 ------ .../templates/deploy/register.sh.tmpl | 52 ----- .../templates/rust/Cargo.toml.tmpl | 21 +- .../templates/rust/build.rs.tmpl | 50 +++++ .../templates/rust/client.rs.tmpl | 199 ++++++++++++++--- .../templates/rust/endpoint.rs.tmpl | 75 ++++--- .../templates/rust/env.example.tmpl | 12 + .../templates/rust/main.rs.tmpl | 41 +++- .../templates/rust/tools.json.tmpl | 7 + 25 files changed, 1014 insertions(+), 684 deletions(-) delete mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/Dockerfile.tmpl delete mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/allowed_leaders.json.tmpl delete mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/cloud-run.mainnet.yaml.tmpl delete mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/cloud-run.testnet.yaml.tmpl delete mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/github-workflow-mainnet.yml.tmpl delete mode 100644 .claude/skills/nexus-tool-builder/templates/deploy/github-workflow-testnet.yml.tmpl delete mode 100755 .claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/rust/build.rs.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/rust/env.example.tmpl create mode 100644 .claude/skills/nexus-tool-builder/templates/rust/tools.json.tmpl diff --git a/.claude/agents/nexus-tool-auditor.md b/.claude/agents/nexus-tool-auditor.md index 0ecd353..afdddea 100644 --- a/.claude/agents/nexus-tool-auditor.md +++ b/.claude/agents/nexus-tool-auditor.md @@ -37,8 +37,8 @@ fixes for clear-cut issues. It must NOT: ## Inputs (collect explicitly before starting) -- **`crate_path`**: absolute path to the crate (e.g. `tools/` for - off-chain, the Move package root for on-chain). +- **`crate_path`**: absolute path to the crate (e.g. `offchain/tools/` + for off-chain, the Move package root for on-chain). - **`kind`**: `off-chain` | `on-chain`. Infer from the presence of `Cargo.toml` vs `Move.toml`. - **`severity_floor`**: `info` | `low` | `medium` | `high` | `critical`. @@ -51,6 +51,31 @@ Read `reference/security-checklist.md` from `.claude/skills/nexus-tool-builder/reference/` before starting — that file is the authoritative list of checks. Don't re-derive them. +## Hard rule — automatic CRITICAL, always blocks promotion + +**Any field in any `Input` struct whose name (case-insensitive)** contains +`api_key`, `apikey`, `secret`, `password`, `private_key`, `bearer`, +`access_token`, `consumer_key`, `consumer_secret`, `client_secret`, ends +with `_token`, or is exactly `token` / `key` — **with the curated +whitelist exceptions** `idempotency_key`, `pagination_token`, +`page_token`, `cursor_token`, `next_token`, `continuation_token`, +`refresh_cursor` — **is an automatic CRITICAL finding** (checklist +**C1**). The tool MUST NOT receive `ready-for-testnet` or +`ready-for-mainnet` until the field is removed and the credential moves to +a startup-time env-var read. + +This rule overrides every other consideration. The credential reaches Sui +the first time the DAG runs, and that is irreversible. The auditor MUST +report `block` and refuse to promote, even if the user asks for testnet +only. + +The `audit.sh` script emits this as `FAIL static:input-credential`. +Cross-check by reading every `Input` struct yourself — the static check +uses a brace-balanced parser but you should not rely on it alone. + +Canonical fix pattern: lift the credential into an env var read at +startup. Reference: `offchain/tools/memory-memwal/src/client.rs::from_env`. + ## Off-chain Rust audit Execute in order; stop only on `critical` findings. @@ -70,10 +95,17 @@ The script wraps: - `cargo +stable test --no-run` (does the test suite compile?) - `cargo audit` (RustSec advisories — install if missing) - `cargo deny check` (uses repo's `deny.toml`) +- `static:input-credential` (the C1 hard rule above) - Grep checks: `unwrap()` / `expect()` / `panic!` inside `async fn invoke`, hardcoded secrets, `println!` / `dbg!` in non-test code, `danger_accept_invalid_certs`, raw `reqwest::Client::new()` without a user-agent +- `tools.json` shape: `tool_name == command == [[bin]].name`; no + secret-looking keys in `environment` (C10). +- Legacy plumbing left behind: refuse if any of `/deploy/`, + `/paths.json`, or per-tool `deploy--{testnet,mainnet}.yml` + workflow files still exist — they're superseded by the shared + `offchain-tools.*` pipeline. Read each finding. Classify severity. @@ -87,12 +119,28 @@ Read `src/main.rs` and every file in `src/tools/`. Verify: top-level `oneOf` — if uncertain, write a one-off test that dumps the schema and asserts the shape. - Every `Input` has `#[serde(deny_unknown_fields)]`. +- **Every `Input` is free of credential-shaped fields (the C1 hard rule + above).** Cross-check the audit script's report by reading each Input + yourself. - Error variants are named with the `err` prefix. - Crucial response fields are NOT behind `Option` — return `Err` instead. - `description()` is overridden (non-empty). - `timeout()` is reasonable (< Leader request budget; > expected upstream latency × 2). +- FQNs are threaded through `env!("TOOL_FQN_VERSION")` — + `fqn!(concat!("xyz.taluslabs....", "@", env!("TOOL_FQN_VERSION")))`. CI + bumps `TOOL_FQN_VERSION` from the tool's subtree git hash, so a bare + `@1` literal stays at `@1` forever and DAGs break. +- The crate's `_client.rs` (or equivalent) reads upstream + credentials from env via `from_env`, wraps in `Zeroizing`, and + hand-implements `Debug` to print `` — no `#[derive(Debug)]` on + the credential-bearing struct. +- `main` runs `load_dotenv_if_present()` and + `validate_credentials_at_startup()` BEFORE building the tokio runtime + (`set_var` is unsound from a multi-threaded process). +- `main` short-circuits on `--meta` so CI's prepare step can pull `/meta` + from an image with no env. ### 3. Information disclosure @@ -120,10 +168,10 @@ tool. Expectations: ### 5. Behavioral backtest -If `tools//fixtures/` exists, replay every recorded response -through the tool with `mockito::Server` and assert the `Output` matches a -golden file. If no fixtures exist, propose creating them from the -canonical happy-path tests. +If `offchain/tools//fixtures/` exists, replay every recorded +response through the tool with `mockito::Server` and assert the `Output` +matches a golden file. If no fixtures exist, propose creating them from +the canonical happy-path tests. ### 6. Dependency hygiene @@ -224,7 +272,7 @@ Read `Move.toml`. Check: ## Report format -Write the report to `tools//AUDIT.md` (off-chain) or +Write the report to `offchain/tools//AUDIT.md` (off-chain) or `/AUDIT.md` (on-chain), overwriting any previous version. Format: ```markdown @@ -277,11 +325,17 @@ Memory peak: MiB ## Conformance checklist +- [ ] **No credential-shaped fields in any Input (C1)** — automatic + CRITICAL if violated - [ ] Output is enum with snake_case rename - [ ] Input has deny_unknown_fields - [ ] All paths unique - [ ] description() set +- [ ] FQN threaded through env!("TOOL_FQN_VERSION") - [ ] No leaking error reasons +- [ ] Credentials read from env at startup; Zeroizing wrapper; redacting Debug +- [ ] tools.json present; tool_name == command == [[bin]].name +- [ ] No legacy per-tool deploy/ or workflow files - [ ] (on-chain) Worksheet stamped on every path - [ ] (on-chain) All entry functions authorized - [ ] … diff --git a/.claude/skills/nexus-tool-builder/SKILL.md b/.claude/skills/nexus-tool-builder/SKILL.md index ac96486..3b0ddf2 100644 --- a/.claude/skills/nexus-tool-builder/SKILL.md +++ b/.claude/skills/nexus-tool-builder/SKILL.md @@ -6,17 +6,21 @@ description: |- "create a Nexus tool for ", "wrap the API as a Talus tool", "scaffold a Nexus tool", "add a tool crate for ", "automate Nexus tool creation", and "on-chain Nexus tool". Supports both off-chain (Rust + - nexus-toolkit) and on-chain (Sui Move) tools, and emits GCP Cloud Run - deployment scaffolding for testnet/mainnet. Skip for non-Talus / non-Nexus - work. + nexus-toolkit) and on-chain (Sui Move) tools. Off-chain tools deploy via the + shared offchain-tools.* reusable workflows; no per-tool deploy plumbing is + emitted. Skip for non-Talus / non-Nexus work. --- # nexus-tool-builder Automates the creation of a new Nexus Tool — off-chain Rust HTTP service or on-chain Sui Move module — by reading an API spec, generating the canonical -Talus tool layout, verifying it builds, and emitting deploy artifacts for GCP -Cloud Run (off-chain) or `sui client publish` (on-chain). +Talus tool layout, and verifying it builds. Off-chain tools live under +`offchain/tools//`; the repo's shared CI pipeline +(`.github/workflows/offchain-tools.{discover,prepare,deploy,register,readiness}.yml`) +discovers them automatically via each tool's `tools.json`. The skill does +NOT emit per-tool Dockerfiles, Cloud Run YAMLs, GitHub workflows, or +`register.sh` — that plumbing is shared and lives outside any single tool. Reference material lives in `reference/`; rendered file templates live in `templates/`. Read the relevant references before generating code: @@ -28,17 +32,19 @@ Reference material lives in `reference/`; rendered file templates live in - `reference/style-guide.md` — port naming, error variants, flatness rules. - `reference/onchain-tools.md` — Move-tool variant (read when `--kind on-chain`). -- `reference/hosting-options.md` — GCP vs DePIN, with a clear v1 default. +- `reference/hosting-options.md` — where deployed tools run. - `reference/security-checklist.md` — authoritative checklist used by the `nexus-tool-auditor` agent. Read it when generating code so you avoid - findings up front. -- `reference/faq.md` — pitfalls (top-level `oneOf`, dedup paths, workspace). + findings up front. **C1 is "no credential-shaped fields in any `Input` + struct" — it gates `ready-for-testnet`.** +- `reference/faq.md` — pitfalls (top-level `oneOf`, dedup paths, `tools.json`, + `BLOCKED_TOOLS`). ## Pre-flight 1. Confirm the working repo is `nexus-tools` (or another repo that pulls - `nexus-toolkit`). Read the workspace `Cargo.toml`; it should declare - `nexus-toolkit` and `nexus-sdk`. + `nexus-toolkit`). Read the workspace `Cargo.toml` at `offchain/Cargo.toml`; + it should declare `nexus-toolkit` and `nexus-sdk`. 1. Confirm the `nexus` CLI is installed: `nexus --version`. If absent, point the user at the install instructions in `Talus-Network/nexus-sdk/cli` before continuing. Do not silently fall back to local-only templates — @@ -51,10 +57,10 @@ Reference material lives in `reference/`; rendered file templates live in - **Kind:** `off-chain` (Rust, default) or `on-chain` (Move). - **API name + canonical docs URL** (off-chain only). - **Category prefix:** `exchanges` | `social` | `storage` | `llm` | `data` | - `defi` | … + `defi` | `payments` | `memory` | … - **Service slug** (kebab-case). Crate name becomes `-`. - **Auth model** (off-chain): `none` | `api_key` | `oauth2` | `signed`. - Drives `client.rs`. + Drives `client.rs` env-var name (e.g. `STRIPE_API_KEY`, `OPENAI_API_KEY`). - **Endpoint set:** explicit list, or `discover-from-docs`. - **FQN domain:** default `xyz.taluslabs`. @@ -66,7 +72,8 @@ Reference material lives in `reference/`; rendered file templates live in 1. **Design ports per endpoint** honoring the style guide: `snake_case` names, error variants prefixed `err`, flat outputs, crucial ports non-optional, split prompt/context-style fields the way the DAG needs - them. + them. **Credentials are never ports** — they come from the process + environment (see step 4 and `reference/security-checklist.md` §C1). 1. **Scaffold via the official CLI:** ```sh @@ -75,30 +82,42 @@ Reference material lives in `reference/`; rendered file templates live in ``` The script wraps `nexus tool new --name --template rust`, moves - the generated crate into `tools/`, and renders the local templates over - it. (Workspace `Cargo.toml` already declares `members = ["tools/*"]`, so - the crate is picked up automatically.) + the generated crate into `offchain/tools/`, and renders the local + templates over it. The crate is picked up automatically by the workspace + (`offchain/Cargo.toml` declares `members = ["tools/*"]`) and by the + discover workflow (which scans `offchain/tools/*/tools.json`). 1. **Generate per-endpoint code** by rendering `templates/rust/endpoint.rs.tmpl` - once per endpoint into `tools//src/tools/.rs`. Each file - contains `Input`, `Output` (enum with `Ok` / `Err`), an `impl NexusTool`, - and a `mockito` test module. Add `pub(crate) mod ;` to - `src/tools/mod.rs`. Append the tool to the `bootstrap!([…])` list in - `src/main.rs`. -1. **Generate `README.md`** with one section per FQN matching - `tools/exchanges-coinbase/README.md`. Include the API docs URL. -1. **Register the crate in the build:** append the new package name to each - `--package` line in `tools/.just` (recipes: `build`, `check`, `test`, - `fmt-check`, `clippy`). -1. **Emit deploy scaffolding** into `tools//deploy/` and - `.github/workflows/`: - - `Dockerfile` (multi-stage Rust → distroless, exposes 8080). - - `cloud-run.testnet.yaml` and `cloud-run.mainnet.yaml` (service config - per env, secret references for signed-HTTP keys, allowed-leaders - ConfigMap). - - `register.sh` (idempotent `nexus tool register` / update). - - `.github/workflows/deploy--testnet.yml` (push to `main`). - - `.github/workflows/deploy--mainnet.yml` (tag `v-*`, - gated on testnet green). + once per endpoint into `offchain/tools//src/tools/.rs`. + Each file contains `Input`, `Output` (enum with `Ok` / `Err`), an + `impl NexusTool`, and a `mockito` test module. Add `pub(crate) mod + ;` to `src/tools/mod.rs`. Append the tool to the + `bootstrap!([…])` list in `src/main.rs`. + + **Credential pattern (mandatory):** the rendered `_client.rs` + reads `_API_KEY` from the process environment once at startup + via `from_env()`, wraps the value in `zeroize::Zeroizing`, and + hand-implements `Debug` to print ``. **Never put `api_key`, + `bearer_token`, `*_secret`, or any credential-shaped field on `Input`.** + Tool inputs are committed to the Nexus DAG on Sui as plaintext — anything + you put there is effectively published. Canonical reference: + `offchain/tools/memory-memwal/src/client.rs`. +1. **Generate `README.md`** with one section per FQN. Include the API docs + URL. Document the required `_API_KEY` env var; do NOT list it as + an Input port. Use placeholders like `$STRIPE_API_KEY` in any example + payload. +1. **Add `tools.json` and `build.rs`.** These are required by the shared + pipeline: + + - `tools.json` — `{ "tool_name": "", "command": "", + "environment": { "RUST_LOG": "info" } }`. **Do NOT list secret env vars + here** — `environment` flows into Cloud Run's `env` block at deploy time + and is visible to anyone with project read. Secrets are mounted via + Cloud Run `secretKeyRef` from GCP Secret Manager, configured by the + operator out-of-band. + - `build.rs` — verbatim copy of `offchain/tools/memory-memwal/build.rs`. + It validates `[[bin]].name == tools.json.command` and threads + `TOOL_FQN_VERSION` from CI's Docker build-arg into the binary via + `env!("TOOL_FQN_VERSION")`. The scaffold script copies it in for you. 1. **Verify** (stop on first failure): ```sh @@ -107,7 +126,9 @@ Reference material lives in `reference/`; rendered file templates live in The script runs `cargo check`, `cargo clippy -- -D warnings`, `cargo test`, `cargo fmt --check` (nightly), and a `cargo run` plus - `/health` and `/meta` smoke test. + `/health` and `/meta` smoke test. It boots the binary with + `_API_KEY=sk_test_FAKE` so `validate_credentials_at_startup` + passes. 1. **Audit** by invoking the `nexus-tool-auditor` sub-agent: @@ -115,7 +136,7 @@ Reference material lives in `reference/`; rendered file templates live in Agent({ subagent_type: "nexus-tool-auditor", description: "Security + conformance audit of ", - prompt: "Audit tools/. kind=off-chain. severity_floor=low. + prompt: "Audit offchain/tools/. kind=off-chain. severity_floor=low. remediation=report-only. Write AUDIT.md and report the CRITICAL/HIGH/MEDIUM counts plus your recommendation (ready-for-testnet | ready-for-mainnet | block)." @@ -123,14 +144,18 @@ Reference material lives in `reference/`; rendered file templates live in ``` Refuse to mark the tool ready if any CRITICAL findings are open. - For testnet, HIGH findings can be filed as follow-up issues. - -1. **Hand off** with the new FQN(s), the path `tools//`, - `just tools run `, the deploy URLs the workflows will create, - the path to `tools//AUDIT.md`, and a reminder to set - `NEXUS_TOOLKIT_CONFIG_PATH` and `signed_http.mode = "required"` before - production. Reference `reference/hosting-options.md` if the user asks - about alternatives to Cloud Run. + For testnet, HIGH findings can be filed as follow-up issues. **A + credential-shaped field in any `Input` struct is always CRITICAL — the + auditor will not promote past `block` until it's removed.** + +1. **Hand off** with the new FQN(s), the path `offchain/tools//`, + the path to `offchain/tools//AUDIT.md`, and a reminder that + Cloud Run deployment is automatic once `tools.json` is present (the + `offchain-tools.discover.yml` workflow picks it up on the next push). + Tell the user the operator must mount `_API_KEY` via Cloud Run + `secretKeyRef` before the tool will pass `/health`. If the user is the + operator, reference the `BLOCKED_TOOLS` repo variable in + `reference/faq.md` as the emergency kill switch. ## On-chain workflow @@ -179,6 +204,17 @@ Reference material lives in `reference/`; rendered file templates live in ## Hard rules — never do these +- **Don't put credential-shaped fields in `Input`.** No `api_key`, + `bearer_token`, `*_secret`, `*_token`, `password`, `private_key`, + `access_token`, `consumer_secret`, `client_secret`. Tool inputs flow + through the Nexus DAG as plaintext on Sui — anything on `Input` is + effectively published. Credentials live in the process environment, read + once at startup. Checklist item **C1**. +- **Read upstream API credentials from env vars at startup, never from + `Input`.** Wrap the value in `zeroize::Zeroizing`. Hand-write + `Debug` to print ``. Validate the env var once in `main` + before `bootstrap!`. Pattern: `offchain/tools/memory-memwal/src/client.rs`. + Checklist items **C7**, **C8**. - **Don't make `Output` a struct.** It must be an enum so the JSON schema emits a top-level `oneOf`. The toolkit and CLI both enforce this; getting it wrong fails at runtime. @@ -190,25 +226,22 @@ Reference material lives in `reference/`; rendered file templates live in - **Don't hardcode a single API call.** Tools must be generic over the API surface — one tool per endpoint, parameterized. - **Don't write to `~/.nexus`, `~/.config`, or any user-global path** from - generated tools. All config goes through `NEXUS_TOOLKIT_CONFIG_PATH`. -- **Don't read upstream API credentials from env vars, files, or any - persistent store.** Credentials live in the `Input` struct ONLY, - passed per request by the DAG / Leader. This matches the repo - convention (see `tools/llm-openai-chat-completion`, - `tools/social-twitter`). See checklist items C7–C11. + generated tools. The only on-disk state allowed is the optional + `/.env` read once at startup by `dotenvy::from_path` (existing + exports always win). - **Don't make the tool stateful.** No `static`, `lazy_static`, - `OnceLock`, `Mutex`, `RwLock`, `Cell`, `RefCell` accumulating - per-request data. No on-disk writes outside `#[cfg(test)]`. The - `nexus-toolkit` runtime calls `new()` per request — anything in the - struct's fields must be cheap to construct and stateless (an - `Arc` is fine; a `Mutex>` is not). - Checklist item C9. -- **Don't derive `Debug` on an `Input` that contains credential - fields.** Hand-write a `Debug` impl that redacts them, or omit Debug - entirely. Checklist item C8. + `OnceLock` (except for shared HTTP clients), `Mutex`, `RwLock`, `Cell`, + `RefCell` accumulating per-request data. The `nexus-toolkit` runtime + calls `new()` per request — anything in the struct's fields must be + cheap to construct and stateless. An `Arc` is fine; a + `Mutex>` is not. Checklist item **C9**. +- **Don't list upstream API keys in `tools.json`'s `environment` block.** + That block becomes Cloud Run's `env`, which is visible to anyone with + project read. Secrets go through Cloud Run `secretKeyRef` from GCP Secret + Manager (operator-configured). Checklist item **C10**. - **Don't put real API keys in README examples or example DAG JSON.** DAG `default_values` get committed to Sui permanently. Use - placeholders like `$STRIPE_API_KEY`. Checklist item C11. + placeholders like `$STRIPE_API_KEY`. Checklist item **C11**. ## Idempotency diff --git a/.claude/skills/nexus-tool-builder/reference/architecture.md b/.claude/skills/nexus-tool-builder/reference/architecture.md index fbcac13..5bc58b5 100644 --- a/.claude/skills/nexus-tool-builder/reference/architecture.md +++ b/.claude/skills/nexus-tool-builder/reference/architecture.md @@ -1,36 +1,51 @@ # Nexus tool crate architecture (off-chain, Rust) -Every off-chain tool crate in `tools/-/` follows the -layout below. The canonical example is `tools/exchanges-coinbase` — read it -when in doubt. +Every off-chain tool crate lives at `offchain/tools/-/` +inside the shared workspace at `offchain/Cargo.toml` (which declares +`members = ["tools/*"]`). The canonical examples are +`offchain/tools/memory-memwal` (env-var credentials, the pattern this skill +generates) and `offchain/tools/exchanges-coinbase` (no upstream credentials). +Read both when in doubt. ```text -tools/-/ -├── Cargo.toml # workspace inheritance; toolkit + sdk + reqwest + schemars + serde + mockito -├── README.md # one section per FQN — Input ports, Output Variants & Ports, docs link -├── deploy/ # added by this skill (not in upstream layout) -│ ├── Dockerfile -│ ├── cloud-run.testnet.yaml -│ ├── cloud-run.mainnet.yaml -│ ├── register.sh -│ └── allowed_leaders.testnet.json # placeholder; real one mounted via Secret Manager +offchain/tools/-/ +├── Cargo.toml # workspace inheritance; toolkit + sdk + reqwest + schemars + serde + mockito + zeroize + dotenvy + env_logger + log +├── README.md # one section per FQN — Input ports, Output Variants & Ports, docs link, ENV VARS section +├── tools.json # { tool_name, command, environment: { RUST_LOG: "info" } } — required for the shared discover workflow +├── build.rs # verbatim copy of offchain/tools/memory-memwal/build.rs — validates [[bin]].name == command and threads TOOL_FQN_VERSION +├── .env.example # documented env vars; never .env (gitignored) └── src/ - ├── main.rs # #![doc = include_str!("../README.md")] + bootstrap!([...]) + ├── main.rs # explicit tokio runtime; env_logger init; --meta short-circuit; dotenv + credential validation BEFORE bootstrap!([...]) ├── error.rs # ErrorKind enum + ErrorResponse + status/api-type mappers - ├── _client.rs # reqwest Client wrapper; .get/.post with typed error + ├── _client.rs # reqwest Client wrapper; from_env() constructor; Zeroizing bearer; redacting Debug; #[cfg(test)] for_testing() └── tools/ ├── mod.rs # const _API_BASE; pub(crate) mod ; shared deserializers ├── models.rs # Shared response types used by ≥2 endpoint modules - ├── .rs # One file per endpoint = one NexusTool impl + ├── .rs # One file per endpoint = one NexusTool impl. NO api_key on Input. └── .rs ``` +**Deployment lives outside the crate.** There is no per-tool `deploy/` +directory, no per-tool `Dockerfile`, no per-tool `cloud-run.*.yaml`, no +per-tool GitHub workflow. The shared `offchain/Dockerfile` builds any tool +by binary name, and the five reusable workflows +(`.github/workflows/offchain-tools.{discover,prepare,deploy,register,readiness}.yml`) +discover the tool via `tools.json`, build the image, push it, register the +FQN on Sui, and reconcile the toolkit-config secret in GCP Secret Manager. +The repo-level `BLOCKED_TOOLS` variable can flip-off any tool from +deployment without a PR. + ## Per-endpoint file template ```rust -//! # `xyz.taluslabs...@1` +//! # `xyz.taluslabs...@` //! //! Standard Nexus Tool that from . +//! +//! ## Credential contract +//! Upstream credentials come from `_API_KEY` env at startup +//! (see `src/_client.rs::from_env`). They are NEVER fields on +//! `Input` — tool inputs flow through the Nexus DAG on Sui as plaintext. use { crate::{ @@ -44,9 +59,10 @@ use { serde::{Deserialize, Serialize}, }; -#[derive(Debug, Deserialize, JsonSchema)] +#[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { + // NO api_key / bearer / secret / token / password / private_key here. /// field: String, optional_field: Option, @@ -76,11 +92,17 @@ impl NexusTool for { type Output = Output; async fn new() -> Self { - Self { client: Client::new(None) } + // Already authed from startup env var. Cheap to construct (Arc clones). + Self { + client: Client::from_env().unwrap_or_else(|e| { + log::error!(" configuration invalid: {e}"); + panic!(" configuration invalid: {e}"); + }), + } } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs...@1") + fqn!(concat!("xyz.taluslabs...@", env!("TOOL_FQN_VERSION"))) } fn path() -> &'static str { "/" } @@ -92,7 +114,7 @@ impl NexusTool for { async fn invoke(&self, input: Self::Input) -> Self::Output { // 1. Validate the input (return Err variant for invalid combinations). // 2. Build the endpoint path. - // 3. Call self.client.get / .post. + // 3. Call self.client.get / .post — no .with_auth() needed. // 4. Map success/error into Output::Ok / Output::Err. } } @@ -103,7 +125,8 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, ) { let server = Server::new_async().await; - let client = Client::new(Some(&server.url())); + // Bypass env-var validation in tests. + let client = Client::for_testing(&server.url(), "sk_test_FAKE_FOR_TESTS_ONLY"); (server, { client }) } @@ -123,12 +146,37 @@ mod _client; mod error; mod tools; -#[tokio::main] -async fn main() { - bootstrap!([ - tools::::, - tools::::, - ]); +fn main() { + // Install env_logger before anything else so dotenv/credential paths + // emit through `log::*`; `bootstrap!`'s own try_init becomes a no-op. + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .init(); + + // CI's prepare step runs `--meta` from a fresh image with no env, so + // skip credential validation in that mode. The toolkit handles --meta + // and exits before any HTTP call is made. + let meta_only = std::env::args().any(|a| a == "--meta"); + if !meta_only { + // dotenv + validation run single-threaded — `set_var` is unsound + // from a multi-threaded process. `main` is the only exit site. + _client::load_dotenv_if_present(); + if let Err(reason) = _client::validate_credentials_at_startup() { + log::error!("{} {reason}", _client::ENV_API_KEY); + std::process::exit(1); + } + } + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime") + .block_on(async { + bootstrap!([ + tools::::, + tools::::, + ]) + }); } ``` @@ -163,25 +211,75 @@ authors.workspace = true keywords.workspace = true categories.workspace = true +[[bin]] +name = "-" +path = "src/main.rs" + [dependencies] +anyhow.workspace = true chrono.workspace = true -thiserror.workspace = true -tokio.workspace = true +dotenvy.workspace = true +env_logger.workspace = true +log.workspace = true reqwest = { workspace = true, features = ["json"] } -serde_json.workspace = true -serde.workspace = true schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +zeroize.workspace = true nexus-toolkit.workspace = true nexus-sdk.workspace = true [dev-dependencies] mockito.workspace = true + +[build-dependencies] +serde_json.workspace = true +toml = "0.8" ``` -## Reference example +## tools.json template + +```json +{ + "tool_name": "-", + "command": "-", + "environment": { + "RUST_LOG": "info" + } +} +``` -`tools/exchanges-coinbase/` — 3 endpoints (`get-spot-price`, -`get-product-ticker`, `get-product-stats`), shared `coinbase_client.rs`, -shared `error.rs`, shared `models.rs`, shared deserializer -(`deserialize_trading_pair`) in `tools/mod.rs`. Copy from it freely. +Notes: + +- `tool_name` and `command` MUST match each other and match `[[bin]].name` + in `Cargo.toml`. `build.rs` enforces this at compile time. +- `environment` is merged into the Cloud Run service's `env` block at + deploy time. **Non-secret config only** (`RUST_LOG`, `*_API_BASE`, + feature flags). Real secrets are mounted via Cloud Run `secretKeyRef` + from GCP Secret Manager — the operator configures these out-of-band, not + the deploy pipeline. + +## build.rs template + +Copy `offchain/tools/memory-memwal/build.rs` verbatim — no template +substitution. It: + +- Validates that `[[bin]].name` in `Cargo.toml` matches `command` in + `tools.json` (compile fails otherwise). +- Reads `TOOL_FQN_VERSION` from the env (set by CI's Docker build-arg from + the tool's subtree git hash) and re-exports it as a cargo env var so the + binary can embed it via `env!("TOOL_FQN_VERSION")`. + +## Reference examples + +- `offchain/tools/memory-memwal/` — env-var credentials (`MEMWAL_DELEGATE_PRIVATE_KEY`), + `Zeroizing` wrapper, redacting `Debug`, `from_env` constructor, + `with_test_config` for tests. **This is the credential pattern this skill + generates.** +- `offchain/tools/storage-walrus/` — env-var fallback for non-secret URLs, + Cloud Run OIDC for upstream auth (no static secrets). +- `offchain/tools/exchanges-coinbase/` — no upstream credentials; reference + for the "public API" case (still uses the same shared pipeline). diff --git a/.claude/skills/nexus-tool-builder/reference/faq.md b/.claude/skills/nexus-tool-builder/reference/faq.md index 5a300d0..a6ef36b 100644 --- a/.claude/skills/nexus-tool-builder/reference/faq.md +++ b/.claude/skills/nexus-tool-builder/reference/faq.md @@ -42,32 +42,68 @@ pub(crate) struct GetSpotPrice { } ``` -Tests inject a `mockito::Server` via `Client::new(Some(&server.url()))`. +In tests, inject a `mockito::Server` via the test-only constructor: +`Client::for_testing(&server.url(), "sk_test_FAKE")`. ## "Where do I put shared response models?" -`src/tools/models.rs`. Use it for `CoinbaseApiResponse` and similar +`src/tools/models.rs`. Use it for `ApiResponse` and similar wrappers shared by ≥2 endpoint modules. Endpoint-specific shapes can live in the endpoint file. ## "How do I handle API auth (API key, OAuth)?" -In `_client.rs`. Read the secret from an env var at -`Client::new`: +Read the secret from an env var **at startup**, not per request. Pattern, +from `offchain/tools/memory-memwal/src/client.rs`: ```rust -let api_key = std::env::var("COINBASE_API_KEY").ok(); +pub(crate) const ENV_API_KEY: &str = "_API_KEY"; + +pub(crate) fn validate_credentials_at_startup() -> Result<(), String> { + let raw = std::env::var(ENV_API_KEY) + .ok() + .map(zeroize::Zeroizing::new); + match classify_key(raw.as_deref().map(|z| z.as_str())) { + KeyOk(_) => Ok(()), + Missing => Err("is not set".into()), + Invalid(reason) => Err(reason), + } +} + +pub fn from_env() -> Result { + let bearer = std::env::var(ENV_API_KEY) + .map_err(|_| format!("{ENV_API_KEY} not set"))?; + Ok(Self { + client: shared_http(), + bearer: zeroize::Zeroizing::new(bearer), + // ... + }) +} ``` -Wire it in Cloud Run via Secret Manager (`secretKeyRef` in the service -YAML). Never log the key — `tracing` filters and `Display` impls have to -exclude it. +In production the env var is mounted by Cloud Run from a Secret Manager +secret (`secretKeyRef` in the service config — operator-configured, not +emitted by the deploy pipeline). + +**Do NOT** put `api_key`, `bearer_token`, `*_secret`, `*_token`, +`password`, `private_key`, `access_token`, `consumer_secret`, or +`client_secret` on any `Input` struct. Tool inputs are committed to the +Nexus DAG on Sui as plaintext. The auditor's `static:input-credential` +check refuses to mark the tool ready if it finds such a field. ## "How do I version a tool?" -The `@1` suffix in the FQN is immutable per registered tool. Output-schema -changes ⇒ bump to `@2` and register a new module/endpoint alongside `@1`, -deprecating `@1` once consumers migrate. Non-schema changes reuse `@1`. +The FQN version (`@N` suffix) is computed by CI from the tool's subtree +git hash and injected at build time via `TOOL_FQN_VERSION`. Threaded into +the binary by `build.rs`, embedded into each FQN via +`fqn!(concat!("xyz.taluslabs.<...>", "@", env!("TOOL_FQN_VERSION")))`. + +This means any source change auto-bumps the FQN version on the next deploy +— existing on-chain registrations are preserved, the new version registers +alongside. The CLI / `nexus-sdk` handles `EFqnAlreadyExists` as idempotent. + +If you want a deterministic version pin for local dev, the `build.rs` +defaults `TOOL_FQN_VERSION=1` when the env var is unset. ## "My tool keeps timing out at 10s in Nexus but works locally" @@ -80,21 +116,54 @@ fn timeout() -> Duration { Duration::from_secs(30) } ## "Do I need signed HTTP locally?" -Not during development. Signed HTTP is enforced only when -`signed_http.mode = "required"` in `NEXUS_TOOLKIT_CONFIG_PATH`. The -templated `cloud-run..yaml` sets that variable; local dev with -`just tools run ` leaves it unset. +Not during development. Signed HTTP is enforced only when the deployed +service mounts a `toolkit-config.json` from Secret Manager (the prepare +workflow does this in production via `NEXUS_TOOLKIT_CONFIG_PATH`). Local +dev runs without it. ## "Workspace `members` line — do I need to update it?" -No. `Cargo.toml` declares `members = ["tools/*"]` so any new directory -under `tools/` is picked up automatically. +No. `offchain/Cargo.toml` declares `members = ["tools/*"]` so any new +directory under `offchain/tools/` is picked up automatically. + +## "How does my tool get into the deploy pipeline?" + +Add `tools.json` to your crate root and push. The +`.github/workflows/offchain-tools.discover.yml` reusable workflow scans +`offchain/tools/*/tools.json`, builds a matrix, and the downstream +prepare/deploy/register/readiness workflows handle the rest. No per-tool +workflow file is needed. (If you've added one — delete it.) + +## "What's `BLOCKED_TOOLS`?" + +A repo-level GitHub Actions variable (Settings → Variables) holding a +space-separated list of `tool_name` values to skip. Set on the repo, no PR +required. The discover workflow drops blocked tools from the matrix, so +they're excluded from build, deploy, register, and Terraform apply. Use +this as the emergency kill switch when a tool ships a vulnerability — +flip it off in repo settings, fix in a follow-up PR, flip it back on. + +As of writing, `http` is blocked due to a missing SSRF guard (the tool +would let a DAG reach the GCP metadata server and exfiltrate an SA OAuth +token; re-enable after the guard ships). + +## "Where do upstream API keys come from in production?" + +Cloud Run mounts them from GCP Secret Manager via `secretKeyRef`. The +operator configures the secret + the service's `env.valueFrom` mapping +out-of-band (it's part of the Terraform/console wiring, not the deploy +pipeline). The pipeline only provisions secrets it owns: -## "What about `tools/.just`?" +- `nexus-tools--v-signed-http-keys` — the tool's own Ed25519 + signing keys (one per FQN). +- `nexus-tools--v-signed-http-toolkit-config` — the toolkit + config JSON with KID mappings. +- `nexus-allowed-leaders-` — the network's Leader public keys. -Yes, you do need to extend the build/check/test/fmt-check/clippy recipes -with `cargo +stable build --package --release` (and equivalents). -The skill does this for you. +Upstream credentials (e.g. `STRIPE_API_KEY`, `OPENAI_API_KEY`) are +**operator-owned secrets**, mounted to the tool's Cloud Run service +through a separate secret + `secretKeyRef` binding that the operator +maintains. ## "On-chain or off-chain?" diff --git a/.claude/skills/nexus-tool-builder/reference/hosting-options.md b/.claude/skills/nexus-tool-builder/reference/hosting-options.md index 0b92aa3..cec6e3d 100644 --- a/.claude/skills/nexus-tool-builder/reference/hosting-options.md +++ b/.claude/skills/nexus-tool-builder/reference/hosting-options.md @@ -1,8 +1,15 @@ # Hosting options for off-chain tools -## v1 default: GCP Cloud Run +## v1 target: GCP Cloud Run (driven by the shared pipeline) -This skill targets **Google Cloud Run** for v1 deployment. Rationale: +This skill targets **Google Cloud Run** via the repo's shared deploy +pipeline. The five reusable workflows +(`.github/workflows/offchain-tools.{discover,prepare,deploy,register,readiness}.yml`) +discover any crate under `offchain/tools/*/tools.json`, build it through +the shared `offchain/Dockerfile`, push to GHCR + GCR, register the FQN on +Sui, and reconcile the toolkit-config secret in GCP Secret Manager. + +Rationale: - Talus's own `tool-communication.md` recommends conventional cloud (Caddy / ALB / managed TLS). No first-party Talus-hosted service exists. @@ -12,18 +19,24 @@ This skill targets **Google Cloud Run** for v1 deployment. Rationale: - Cloud Run gives managed TLS, scale-to-zero, Workload Identity for secrets, and Cloud Logging out of the box. Lowest friction for shipping. -The `templates/deploy/` folder emits: +**The skill emits no per-tool deploy plumbing.** It does not write a +`Dockerfile`, `cloud-run.*.yaml`, `register.sh`, or a per-tool GitHub +workflow. All of that is centralized. The skill's job is to produce the +tool crate (with `tools.json` + `build.rs`) and stop. + +## Secret model (Cloud Run) -- A multi-stage `Dockerfile` (Rust builder → distroless runtime, port 8080). -- `cloud-run.testnet.yaml` and `cloud-run.mainnet.yaml` (one Cloud Run - service per env, secret refs, allowed-leaders config). -- `register.sh` (idempotent `nexus tool register` / update). -- Two GitHub Actions workflows (testnet on push to `main`, mainnet on tag). +| Secret | Owner | Naming | Notes | +| --- | --- | --- | --- | +| Tool's own signed-HTTP keys (per FQN) | Pipeline (auto) | `nexus-tools--v-signed-http-keys` | Generated by `offchain-tools.prepare.yml`. Idempotent — regenerated only when FQN set changes. | +| Toolkit config (KID mapping) | Pipeline (auto) | `nexus-tools--v-signed-http-toolkit-config` | Reconciled by `offchain-tools.register.yml`. Mounted at `/app/secrets/toolkit-config.json` (via `NEXUS_TOOLKIT_CONFIG_PATH`). | +| Network Leader public keys | Operator (manual) | `nexus-allowed-leaders-` | Updated when Leader set changes. | +| **Upstream API keys (e.g. `STRIPE_API_KEY`)** | **Operator (manual)** | Operator-chosen | **Mounted via `secretKeyRef` on the Cloud Run service.** The deploy pipeline does NOT provision these — the operator binds them to the service description out-of-band. The skill's `tools.json` MUST NOT list them in `environment`. | ## Alternatives (per-tool migration targets) | Provider | Fit | Trade-off | -|---|---|---| +| --- | --- | --- | | **Akash Network** | Best general DePIN fit. Mature compute marketplace, supports arbitrary Docker HTTPS services. | More ops friction than Cloud Run; debugging tooling weaker. | | **Spheron** | Web3 cloud aggregator on top of AWS / Akash. Closest to managed-deploy UX with a DePIN backend. | Newer; smaller ecosystem than Cloud Run. | | **Atoma Network** | Sui-native AI inference DePIN. Best fit when the tool itself is LLM inference. | Not general-purpose hosting — inference workloads only. | @@ -33,15 +46,21 @@ The `templates/deploy/` folder emits: ## Migration path -The `Dockerfile` the skill emits is portable. To move a single tool to -Akash or Spheron later: +The shared `offchain/Dockerfile` produces a portable image (distroless, +linux/amd64). To move a single tool off Cloud Run later: -1. Push the existing image to a public registry (Docker Hub / GHCR). +1. Pull the image the pipeline pushed (e.g. + `gcr.io//nexus-tools/:sha-`). +1. Push it to the new registry (Docker Hub / GHCR / Akash provider). 1. Write a small SDL (Akash) or Spheron config that references the image. + Provide the same env vars the Cloud Run service had — including any + upstream API key — through the new provider's secret mechanism. 1. Run `nexus tool register offchain --tool-fqn --url ` to point Nexus at the new URL. Idempotent — the FQN stays the same. -No Rust code changes required. +Add the tool to `BLOCKED_TOOLS` on the GitHub repo Variables to stop the +shared Cloud Run pipeline from re-deploying it. No Rust code changes +required. ## When to revisit this decision diff --git a/.claude/skills/nexus-tool-builder/reference/interface.md b/.claude/skills/nexus-tool-builder/reference/interface.md index 125dde2..610a2b4 100644 --- a/.claude/skills/nexus-tool-builder/reference/interface.md +++ b/.claude/skills/nexus-tool-builder/reference/interface.md @@ -5,14 +5,14 @@ Source: `nexus-sdk/toolkit-rust/src/nexus_tool.rs`. ## Required associated types | Type | Constraints | Purpose | -|---|---|---| +| --- | --- | --- | | `Input` | `JsonSchema + DeserializeOwned + Send` | Generates the input schema. Deserialized from the request body. | | `Output` | `JsonSchema + Serialize + Send`, **must be a Rust `enum`** so the schema gets a top-level `oneOf` | Generates the output schema. Serialized into the response. | ## Required methods | Method | Signature | Notes | -|---|---|---| +| --- | --- | --- | | `fqn` | `fn fqn() -> ToolFqn` | Use the `fqn!("xyz.taluslabs...@1")` macro. | | `new` | `async fn new() -> Self` | Called per request. Inject dependencies here. | | `invoke` | `async fn invoke(&self, input: Self::Input) -> Self::Output` | Main logic. **No `Result`** — errors go in `Output::Err`. | @@ -21,7 +21,7 @@ Source: `nexus-sdk/toolkit-rust/src/nexus_tool.rs`. ## Optional methods (have defaults) | Method | Default | When to override | -|---|---|---| +| --- | --- | --- | | `path` | `""` (root) | When multiple tools live in one crate — each needs a unique path. | | `description` | `""` | Always — surfaces in `/meta`. | | `timeout` | `Duration::from_secs(10)` | Override for slow upstreams; keep below the Leader's request budget. | @@ -30,7 +30,7 @@ Source: `nexus-sdk/toolkit-rust/src/nexus_tool.rs`. ## Generated endpoints (from `NexusTool` impl) | Endpoint | Body | -|---|---| +| --- | --- | | `GET /health` | Status code returned by `health()`. | | `GET /meta` | JSON: `{ fqn, url, timeout, description, input_schema, output_schema }`. | | `POST /invoke` | Deserializes body as `Input`, calls `invoke`, serializes the result as `Output`. | diff --git a/.claude/skills/nexus-tool-builder/reference/security-checklist.md b/.claude/skills/nexus-tool-builder/reference/security-checklist.md index cdc86b1..b1e5374 100644 --- a/.claude/skills/nexus-tool-builder/reference/security-checklist.md +++ b/.claude/skills/nexus-tool-builder/reference/security-checklist.md @@ -7,30 +7,30 @@ its `AUDIT.md` output. ## Off-chain (Rust) — CRITICAL | # | Check | Why it matters | -|---|---|---| -| C1 | `Output` is a Rust `enum` (not a struct) with `#[serde(rename_all = "snake_case")]` | Nexus runtime rejects non-`oneOf` schemas. | -| C2 | No `unwrap()` / `expect()` / `panic!` on any path reachable from `invoke()` | Panics surface as opaque 500s; Nexus expects typed `Err` variants. | -| C3 | No `danger_accept_invalid_certs` or any TLS bypass | Leader-side TLS verification is part of the Nexus trust model. | -| C4 | No hardcoded API keys, tokens, or signing material in source or test fixtures | Source leaks become public on first push. | -| C5 | `Output::Err::reason` does NOT contain raw upstream bodies, request URLs with secrets, internal file paths, or stack frames | Errors are passed on-chain unredacted. | -| C6 | Tool does NOT call `std::process::exit`, `unsafe`, or spawn child processes | Breaks the toolkit's lifecycle assumptions. | -| C7 | Tool source MUST NOT read any upstream-API credential from env vars, disk, or any persistent store. Credentials live ONLY in `Input` struct fields. | Repo-wide convention; matches `tools/llm-openai-chat-completion`, `tools/social-twitter`. Reading credentials from env breaks multi-tenancy and concentrates secret material on the Tool host. | -| C8 | Tool MUST NOT log, print, format-debug, or include in `Output::Err::reason` any value of a credential field on `Input`. `Input` MUST NOT derive `Debug` automatically — if you need Debug, hand-write one that omits credential fields. | Logs end up in Cloud Logging where many people can read them. | -| C9 | Tool MUST be **stateless across invocations**. No `static`, `lazy_static`, `OnceLock`, `Mutex`, `RwLock`, `Cell`, `RefCell` carrying per-request data. No on-disk writes outside `#[cfg(test)]`. Same input ⇒ same upstream call. | `nexus-toolkit` calls `new()` per request; stateful tools produce non-deterministic outputs, break retries, and turn the Tool host into a trust point. Configuration constants (URLs, version strings) are fine; mutable shared state is not. | -| C10 | Tool MUST NOT mount upstream API keys as Cloud Run secrets. The only secrets bound to the Cloud Run service are `nexus-toolkit-config--` (Tool's own Ed25519 signing key) and `nexus-allowed-leaders-` (public keys). | Custodying upstream keys on the Tool host violates the credential model in C7 and creates a juicy exfiltration target. | +| --- | --- | --- | +| C1 | **No credential-shaped fields in any `Input` struct.** Field names (case-insensitive) MUST NOT contain `api_key`, `apikey`, `secret`, `password`, `private_key`, `bearer`, `access_token`, `consumer_key`, `consumer_secret`, `client_secret`, end with `_token`, or be exactly `token` / `key`. The legitimate per-call exceptions are `idempotency_key`, `pagination_token`, `page_token`, `cursor_token`, `next_token`, `continuation_token`, `refresh_cursor`. | Tool inputs flow through the Nexus DAG as plaintext on Sui. Any credential-shaped field on `Input` is effectively published on-chain. Credentials MUST be sourced from the process environment at startup. Canonical pattern: `offchain/tools/memory-memwal/src/client.rs::from_env`. | +| C2 | `Output` is a Rust `enum` (not a struct) with `#[serde(rename_all = "snake_case")]` | Nexus runtime rejects non-`oneOf` schemas. | +| C3 | No `unwrap()` / `expect()` / `panic!` on any path reachable from `invoke()` | Panics surface as opaque 500s; Nexus expects typed `Err` variants. | +| C4 | No `danger_accept_invalid_certs` or any TLS bypass | Leader-side TLS verification is part of the Nexus trust model. | +| C5 | No hardcoded API keys, tokens, or signing material in source or test fixtures | Source leaks become public on first push. | +| C6 | `Output::Err::reason` does NOT contain raw upstream bodies, request URLs with secrets, internal file paths, or stack frames | Errors are passed on-chain unredacted. | +| C7 | Tool source MUST read upstream-API credentials from environment variables at startup, via a `zeroize::Zeroizing` wrapper, validated once in `main` before `bootstrap!`. The env var name follows `_API_KEY` (or `_*` for multi-secret services). | Per-request credentials on `Input` violate C1 (on-chain leak). The startup-env model centralizes the trust boundary on the Cloud Run service identity (Secret Manager `secretKeyRef` + IAM), matches `offchain/tools/memory-memwal`, `offchain/tools/storage-walrus`, and gives a single redactable log site. | +| C8 | Tool MUST NOT log, print, format-debug, or include in `Output::Err::reason` any value of a credential. The struct holding the credential MUST hand-implement `Debug` to print `` (never `#[derive(Debug)]`). | Logs end up in Cloud Logging where many people can read them. Heap and stack copies of the secret are wiped via `Zeroizing`. | +| C9 | Tool MUST be **stateless across invocations** w.r.t. request data. The credential / HTTP-client / signing-key fields constructed once at startup and shared via `Arc` are fine and required. No mutable per-request state in `static`, `lazy_static`, `OnceLock`, `Mutex`, `RwLock`, `Cell`, `RefCell`. No on-disk writes outside `#[cfg(test)]`. Same input ⇒ same upstream call. | `nexus-toolkit` calls `new()` per request; mutable shared request state produces non-deterministic outputs, breaks retries, and turns the Tool host into a trust point. | +| C10 | The tool's `tools.json` `environment` block MUST NOT contain upstream API keys, tokens, or any secret. It is rendered into the Cloud Run service's `env` block at deploy time and is visible to anyone with project read. Real secrets are mounted via Cloud Run `secretKeyRef` from GCP Secret Manager, configured out-of-band by the operator (the deploy pipeline does not provision them). | `environment` is for non-secret runtime config (e.g. `RUST_LOG=info`). Putting an API key here makes it readable from the Cloud Run service description, defeating the secret-mount model. | | C11 | The crate's `README.md` and any example DAG JSON MUST NOT contain credential-shaped values (`sk_live_`, `sk_test_<64 chars>`, `Bearer `). Use placeholder names like `$STRIPE_API_KEY`. | DAG `default_values` get written to Sui permanently. Real keys in READMEs leak via git history. | ## Off-chain — HIGH | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | H1 | `Input` has `#[serde(deny_unknown_fields)]` | Stops silent acceptance of typos / injection of unknown control fields. | | H2 | Every `NexusTool::path()` is unique within the crate | `bootstrap!` will mount duplicates at the same route; behavior is undefined. | | H3 | `NexusTool::timeout()` is overridden to a value below the Leader request budget but above 2× expected upstream latency | Default 10s is too short for many real APIs and too long for cheap ones. | | H4 | All `reqwest::Client` instances set a stable user-agent | Some APIs (Coinbase) reject empty user-agents intermittently. | | H5 | `cargo audit` reports no known advisories | Public CVE; pre-existing. | -| H6 | API keys read via `std::env::var` are never logged with `tracing` / `log` / `println!` | Logs end up in Cloud Logging, accessible to anyone with project read. (Note: per C7 you shouldn't be reading them from env at all.) | -| H7 | Every credential-bearing `Input` field is named on the convention `api_key`, `bearer_token`, `*_secret`, `*_token`, so the auditor's name-pattern detector can find them. | The auditor can't tell a port is sensitive otherwise. | +| H6 | Startup-time env-var reads of secrets are NOT logged with `tracing` / `log` / `println!`. The classification log line (e.g. "STRIPE_API_KEY is not set") is fine; the value itself never appears. | Logs end up in Cloud Logging, accessible to anyone with project read. | +| H7 | The crate's `tools.json` has `tool_name`, `command`, and at minimum `environment.RUST_LOG`. `tool_name` and `command` MUST match `[[bin]].name` in `Cargo.toml` (the shared `build.rs` enforces this at compile time). | The shared discover/prepare workflows key off these fields; missing or mismatched values silently exclude the tool from CI deploys. | | H8 | Write endpoints (POST/PUT/PATCH/DELETE) include `idempotency_key: Option` in `Input` and pass it through as the `Idempotency-Key` header. | Leaders retry on transient failures; without this, retries double-charge / double-write. | | H9 | For Stripe-style APIs: tests / fixtures use `sk_test_` prefixed keys only. No `sk_live_`, `pk_live_`, or other production-prefix tokens anywhere in source. | Bricks of fines and reputation damage if a real live key lands in git. | | H10 | Stripe-style tools cover every documented error class in `from_api_error_type`: `card_error`, `validation_error`, `invalid_request_error`, `idempotency_error`, `rate_limit_error`, `authentication_error`, `api_error`. | Unmapped error types degrade to `Unknown`, hiding the actual failure mode from DAG authors. | @@ -38,18 +38,18 @@ its `AUDIT.md` output. ## Off-chain — MEDIUM | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | M1 | `NexusTool::description()` is non-empty | `/meta` exposes this to DAG authors. | | M2 | Every endpoint has a `mockito`-backed happy-path test | Regressions caught at PR time. | | M3 | Every endpoint has at least one error-variant test | Same. | | M4 | Crucial response fields are non-optional in `Output::Ok` | Forces `Err` variant when upstream omits required data. | -| M5 | No `println!` / `eprintln!` / `dbg!` outside `#[cfg(test)]` code | Pollutes Cloud Logging; can leak data. | +| M5 | No `println!` / `eprintln!` / `dbg!` outside `#[cfg(test)]` code | Pollutes Cloud Logging; can leak data. Use `log::*` instead. | | M6 | Workspace dep versions (`workspace = true`) — no per-crate version drift | Hard-to-trace upgrade pain. | ## Off-chain — LOW / INFO | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | L1 | Doc comments on every `Input` / `Output` field — they show up in `input_schema` / `output_schema` | DAG authors need this. | | L2 | `Cargo.toml` `description` is set | Helps `cargo metadata` consumers. | | L3 | README has one section per FQN | Convention; consumed by tooling. | @@ -57,7 +57,7 @@ its `AUDIT.md` output. ## On-chain (Move) — CRITICAL | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | C-M1 | `execute` first parameter is `worksheet: &mut ProofOfUID`, last is `ctx: &mut TxContext`, returns `TaggedOutput` | Runtime enforces this signature. | | C-M2 | `worksheet.stamp_with_data(&witness.id, …)` is called on **every** code path before `execute` returns | Without it the Nexus runtime rejects the proof. Easy to miss in early-return branches. | | C-M3 | Witness struct does NOT have the `copy` ability | A copyable witness lets anyone forge proofs. | @@ -68,7 +68,7 @@ its `AUDIT.md` output. ## On-chain — HIGH | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | H-M1 | Output enum has at least one `err`-prefixed variant | Nexus treats them as error variants. | | H-M2 | TaggedOutput field types use the right `as_*()` (number/string/bool/address/raw) | Wrong typing breaks downstream tools at the schema layer, after the Move call succeeded. | | H-M3 | No unbounded loops over caller-controlled `vector` or dynamic field reads | Gas grief / out-of-budget aborts. | @@ -79,7 +79,7 @@ its `AUDIT.md` output. ## On-chain — MEDIUM | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | M-M1 | No `friend` modules outside this package | `friend` widens access; reviewers need to follow the trust path. | | M-M2 | Every shared object is initialized in `init` and not re-creatable | Drift between the published state and the registered witness id breaks Nexus. | | M-M3 | Test for the witness-stamping flow (positive assertion that the worksheet has the expected stamp after `execute`) | Catches silent misuse of `stamp_with_data`. | @@ -87,15 +87,15 @@ its `AUDIT.md` output. ## On-chain — LOW / INFO | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | L-M1 | Doc comments on `execute` and on every `Output` variant | Schema generation reads them. | | L-M2 | Module path matches the FQN convention `..@` | Convention; helps discovery. | ## Cross-cutting (both kinds) | # | Check | Why it matters | -|---|---|---| -| X1 | FQN version (`@1`) is bumped when output schema changes | Existing DAGs break otherwise. | +| --- | --- | --- | +| X1 | FQN version (`@N`) is bumped when output schema changes. For off-chain tools, the version comes from `env!("TOOL_FQN_VERSION")` (CI computes it from the tool's subtree git hash), so any source change auto-bumps the version on the next deploy. | Existing DAGs break otherwise. | | X2 | `description` (Rust) / module-level docstrings (Move) accurately describe behavior | Misleading descriptions break DAG composition by humans and agents. | | X3 | Tool is **idempotent** under retry — same input ⇒ same output, side-effect-free for read tools | The Leader retries on transient failures. | | X4 | Tool does NOT trust the request body for authentication — only the signed-HTTP layer (off-chain) or the worksheet (on-chain) | Bypass otherwise. | @@ -103,7 +103,9 @@ its `AUDIT.md` output. ## How the auditor maps findings to severity - **CRITICAL**: production-shipping the tool with this finding open exposes - funds, secrets, or DAG correctness. + funds, secrets, or DAG correctness. **C1 (credential in Input) is always + CRITICAL and always blocks `ready-for-testnet`** — there is no + testnet-but-not-mainnet middle ground for an on-chain credential leak. - **HIGH**: causes incorrect behavior or significantly weakens defense in depth. Block mainnet, allow testnet with a follow-up issue. - **MEDIUM**: degraded UX, missing test, or non-blocking conformance gap. diff --git a/.claude/skills/nexus-tool-builder/reference/style-guide.md b/.claude/skills/nexus-tool-builder/reference/style-guide.md index 0e30d0a..ceda8b1 100644 --- a/.claude/skills/nexus-tool-builder/reference/style-guide.md +++ b/.claude/skills/nexus-tool-builder/reference/style-guide.md @@ -6,7 +6,9 @@ Distilled from `Talus-Network/nexus-sdk/docs/tool-development.md`. - All port names (Input Ports, Output Variants, Output Ports) are `snake_case`. Never `camelCase` / `PascalCase` / `APIKey`. -- Names are descriptive and concise: `api_key`, not `k` or `apk`. +- Names are descriptive and concise: `payment_intent_id`, not `pi` or `id`. + (Note: `api_key`, `bearer_token`, and other credential-shaped names are + forbidden anywhere on `Input` — see `security-checklist.md` §C1.) - Erroneous output variants start with `err`: `err`, `err_http`, `err_quota`. Never `error`, `failure`, `http_exception`. @@ -28,7 +30,7 @@ Err { ## Interface design | ✅ Do | ❌ Don't | -|---|---| +| --- | --- | | Build a tool that encapsulates the API's surface (one tool per endpoint, parameterized). | Build a tool that only does one hardcoded call (e.g. "BTC-USD spot price"). | | Split `prompt` and `context` into separate input ports even if the API merges them. | Merge them into one input port — the DAG can't set defaults for fields combined with edge data. | | Accept `json_schema` as input and validate generic responses against it (where applicable). | Hardcode the output schema for a single endpoint when the underlying API serves many. | diff --git a/.claude/skills/nexus-tool-builder/scripts/audit.sh b/.claude/skills/nexus-tool-builder/scripts/audit.sh index cc7f004..203c9e6 100755 --- a/.claude/skills/nexus-tool-builder/scripts/audit.sh +++ b/.claude/skills/nexus-tool-builder/scripts/audit.sh @@ -6,6 +6,9 @@ # Usage: # audit.sh off-chain # audit.sh on-chain +# +# Off-chain crates live at offchain/tools//. The workspace is at +# offchain/Cargo.toml. set -uo pipefail @@ -15,6 +18,8 @@ TARGET="${2:?missing second arg: crate name (off-chain) or path (on-chain)}" REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" cd "$REPO_ROOT" +WORKSPACE_MANIFEST="offchain/Cargo.toml" + # Each check writes a single line "STATUS\tCHECK\tDETAIL" to the report. REPORT="$(mktemp)" PASS=0 @@ -37,16 +42,86 @@ require() { off_chain() { local crate="$1" - local crate_dir="tools/$crate" + local crate_dir="offchain/tools/$crate" if [[ ! -d "$crate_dir" ]]; then emit FAIL "crate:exists" "$crate_dir not found" return fi + # ---- C1 (CRITICAL): credential-shaped Input fields --------------------- + # This is the central security check. Tool inputs flow through the Nexus + # DAG on Sui as plaintext, so any credential-shaped field on Input is + # effectively published on-chain. Refuse to mark the tool ready if any + # match is found. + # + # Detection rule: + # - field name (case-insensitive) contains any of: api_key, apikey, + # secret, password, private_key, bearer, access_token, consumer_key, + # consumer_secret, client_secret, ends with _token, or is exactly + # `token` / `key` + # - EXCEPT for the curated whitelist of legitimately public per-call + # values: idempotency_key, pagination_token, page_token, cursor_token, + # next_token, continuation_token, refresh_cursor + : > /tmp/audit-input-cred.log + local cred_keywords='api_key|apikey|secret|password|private_key|bearer|access_token|consumer_key|consumer_secret|client_secret' + local cred_suffix='_token$' + local cred_exact='^(token|key)$' + local cred_whitelist='^(idempotency_key|pagination_token|page_token|cursor_token|next_token|continuation_token|refresh_cursor)$' + while IFS= read -r f; do + [[ -z "$f" ]] && continue + # awk: find every line of every `struct Input` block (between the + # struct opener and the matching closing brace at column 1) and check + # field names. Field syntax: `pub : ...` or `: ...`. + awk -v file="$f" \ + -v keywords="$cred_keywords" \ + -v suffix="$cred_suffix" \ + -v exact="$cred_exact" \ + -v whitelist="$cred_whitelist" ' + BEGIN { in_input=0; depth=0 } + # Match the Input struct header. Note: deliberately NOT using + # IGNORECASE — it breaks gawks regex on subsequent lines for unclear + # reasons. Field-name matching uses tolower() instead. + /^[[:space:]]*(pub(\([^)]*\))?[[:space:]]+)?struct Input[[:space:]<{]/ { + in_input=1; depth=0 + } + in_input { + # naive brace counter — works for single-Input-per-file crates, + # which is the universal convention. + for (i=1; i<=length($0); i++) { + c=substr($0,i,1) + if (c=="{") depth++ + else if (c=="}") { depth--; if (depth==0) { in_input=0; next } } + } + # Field detection: strip leading whitespace, optional `pub`, capture + # the identifier before the colon. + line=$0 + sub(/^[[:space:]]*/, "", line) + sub(/^pub(\([^)]*\))?[[:space:]]+/, "", line) + if (match(line, /^[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*:/)) { + field=substr(line, RSTART, RLENGTH) + sub(/[[:space:]]*:.*$/, "", field) + field_lc = tolower(field) + # Whitelist first. + if (field_lc ~ whitelist) next + # Then match against credential patterns. + if (field_lc ~ keywords || field_lc ~ suffix || field_lc ~ exact) { + print file ":" NR " Input." field + } + } + } + ' "$f" >> /tmp/audit-input-cred.log + done < <(grep -rEl 'struct Input\b' "$crate_dir/src" --include='*.rs' 2>/dev/null || true) + + if [[ -s /tmp/audit-input-cred.log ]]; then + emit FAIL "static:input-credential" "C1 violation — credential on Input flows to chain. See /tmp/audit-input-cred.log" + else + emit PASS "static:input-credential" + fi + # ---- Static: cargo ------------------------------------------------------ if require cargo; then - if cargo +stable check --all-targets --package "$crate" 2>&1 | tee /tmp/audit-check.log | tail -5 >/dev/null; then + if cargo +stable check --manifest-path "$WORKSPACE_MANIFEST" --all-targets --package "$crate" 2>&1 | tee /tmp/audit-check.log | tail -5 >/dev/null; then if grep -q 'error\[' /tmp/audit-check.log; then emit FAIL "cargo:check" "see /tmp/audit-check.log" else @@ -56,7 +131,7 @@ off_chain() { emit FAIL "cargo:check" "non-zero exit" fi - if cargo +stable clippy --all-targets --all-features --package "$crate" -- -D warnings 2>&1 | tee /tmp/audit-clippy.log | tail -5 >/dev/null; then + if cargo +stable clippy --manifest-path "$WORKSPACE_MANIFEST" --all-targets --all-features --package "$crate" -- -D warnings 2>&1 | tee /tmp/audit-clippy.log | tail -5 >/dev/null; then if grep -q 'warning:\|error\[' /tmp/audit-clippy.log; then emit FAIL "cargo:clippy" "see /tmp/audit-clippy.log" else @@ -66,7 +141,7 @@ off_chain() { emit FAIL "cargo:clippy" "non-zero exit" fi - if cargo +stable test --no-run --package "$crate" 2>&1 | tail -5 >/dev/null; then + if cargo +stable test --manifest-path "$WORKSPACE_MANIFEST" --no-run --package "$crate" 2>&1 | tail -5 >/dev/null; then emit PASS "cargo:test-compiles" else emit FAIL "cargo:test-compiles" @@ -74,7 +149,7 @@ off_chain() { fi if require cargo-audit; then - if cargo audit 2>&1 | tee /tmp/audit-audit.log | grep -q 'Crate:'; then + if (cd offchain && cargo audit) 2>&1 | tee /tmp/audit-audit.log | grep -q 'Crate:'; then emit FAIL "cargo:audit" "advisories present — see /tmp/audit-audit.log" else emit PASS "cargo:audit" @@ -82,7 +157,7 @@ off_chain() { fi if require cargo-deny; then - if cargo deny check 2>&1 | tail -5 >/dev/null; then + if (cd offchain && cargo deny check) 2>&1 | tail -5 >/dev/null; then emit PASS "cargo:deny" else emit WARN "cargo:deny" "non-zero exit" @@ -91,8 +166,8 @@ off_chain() { # ---- Static: greps ------------------------------------------------------ # 1. unwrap/expect/panic — flag any usage in non-test code. Caller can - # rule out startup-only unwraps (StripeClient::new builder) when - # writing AUDIT.md, but every site needs eyeballing. + # rule out startup-only unwraps (Client::from_env in NexusTool::new) + # when writing AUDIT.md, but every site needs eyeballing. : > /tmp/audit-unwrap.log while IFS= read -r f; do grep -nE '\b(unwrap|expect|panic!)\b' "$f" 2>/dev/null \ @@ -100,17 +175,13 @@ off_chain() { | awk -v file="$f" -F: '{print file":"$0}' \ >> /tmp/audit-unwrap.log || true done < <(find "$crate_dir/src" -name '*.rs' -not -path '*/tests/*') - # Exclude lines inside #[cfg(test)] modules. Simple heuristic: drop - # any file path that already contains `tests` in its name; for - # in-file `mod tests {}` blocks, the `mod tests` grep -v above - # excludes the contents heuristically. if [[ -s /tmp/audit-unwrap.log ]]; then emit WARN "static:unwrap-in-non-test" "$(wc -l /tmp/audit-println.log || true if [[ -s /tmp/audit-println.log ]]; then @@ -119,14 +190,14 @@ off_chain() { emit PASS "static:println" fi - # 3. danger_accept_invalid_certs + # 3. danger_accept_invalid_certs (C4) if grep -rn 'danger_accept_invalid_certs' "$crate_dir/src" --include='*.rs' >/dev/null; then - emit FAIL "static:disabled-tls-verify" "danger_accept_invalid_certs present" + emit FAIL "static:disabled-tls-verify" "C4 violation — danger_accept_invalid_certs present" else emit PASS "static:disabled-tls-verify" fi - # 4. hardcoded secrets (heuristic) + # 4. hardcoded secrets (C5 heuristic) if grep -rEn '(api[_-]?key|secret|token|bearer)\s*=\s*"[A-Za-z0-9_\-]{16,}"' "$crate_dir/src" --include='*.rs' >/tmp/audit-secrets.log; then if [[ -s /tmp/audit-secrets.log ]]; then emit WARN "static:hardcoded-secret" "see /tmp/audit-secrets.log" @@ -137,12 +208,11 @@ off_chain() { emit PASS "static:hardcoded-secret" fi - # 4b. Stripe-style live-key leak detector. Require ≥16 alnum chars - # after the prefix to avoid matching README explanatory text like - # "sk_live_..." or "use sk_live_ for production". + # 4b. Stripe-style live-key leak detector (C11). Require ≥16 alnum chars + # after the prefix to avoid matching README explanatory text. if grep -rEn '(sk|pk|rk)_live_[A-Za-z0-9]{16,}' "$crate_dir" --include='*.rs' --include='*.md' --include='*.json' --include='*.yaml' --include='*.yml' >/tmp/audit-livekey.log; then if [[ -s /tmp/audit-livekey.log ]]; then - emit FAIL "static:live-key-leak" "see /tmp/audit-livekey.log" + emit FAIL "static:live-key-leak" "C11 violation — see /tmp/audit-livekey.log" else emit PASS "static:live-key-leak" fi @@ -150,19 +220,10 @@ off_chain() { emit PASS "static:live-key-leak" fi - # 4c. credentials sourced from env (C7 violation) - if grep -rEn 'std::env::var\(\s*"[A-Z_]*(KEY|SECRET|TOKEN|PASSWORD|BEARER)' "$crate_dir/src" --include='*.rs' >/tmp/audit-env-cred.log; then - if [[ -s /tmp/audit-env-cred.log ]]; then - emit FAIL "static:env-credential" "C7 violation: credentials must come via Input, not env. See /tmp/audit-env-cred.log" - else - emit PASS "static:env-credential" - fi - else - emit PASS "static:env-credential" - fi - - # 4d. statefulness sniff (C9): mutable shared state across requests - if grep -rEn '\b(lazy_static!|OnceLock|once_cell|Mutex<|RwLock<|RefCell<|static mut|AtomicU)' "$crate_dir/src" --include='*.rs' \ + # 4c. statefulness sniff (C9): mutable shared state across requests. + # OnceLock is allowed (used by clients for shared HTTP pools); flag + # OnceCell/lazy_static/Mutex/RwLock/RefCell/static-mut/AtomicU instead. + if grep -rEn '\b(lazy_static!|once_cell|Mutex<|RwLock<|RefCell<|static mut|AtomicU)' "$crate_dir/src" --include='*.rs' \ | grep -v 'mod tests' >/tmp/audit-stateful.log; then if [[ -s /tmp/audit-stateful.log ]]; then emit FAIL "static:stateful" "C9 violation: tool must be stateless. See /tmp/audit-stateful.log" @@ -173,7 +234,7 @@ off_chain() { emit PASS "static:stateful" fi - # 4e. on-disk writes outside tests + # 4d. on-disk writes outside tests if grep -rEn 'std::fs::(write|create|create_dir|File::create|File::open\(\s*[^"])' "$crate_dir/src" --include='*.rs' \ | grep -v 'mod tests' >/tmp/audit-fs.log; then if [[ -s /tmp/audit-fs.log ]]; then @@ -185,8 +246,10 @@ off_chain() { emit PASS "static:on-disk-write" fi - # 4f. Debug derived on a struct that contains api_key (C8 violation) - if grep -rEn -B3 'pub api_key:|pub bearer_token:|pub .*_secret:|pub .*_token:' "$crate_dir/src" --include='*.rs' \ + # 4e. Debug derived on a struct that contains a bearer / secret / + # api_key / private_key / token field (C8 violation). The credential + # type should hand-implement Debug to print . + if grep -rEn -B3 'pub bearer:|pub api_key:|pub .*_secret:|pub .*_token:|pub private_key:' "$crate_dir/src" --include='*.rs' \ | grep -E '#\[derive\([^)]*Debug' >/tmp/audit-debug-cred.log; then if [[ -s /tmp/audit-debug-cred.log ]]; then emit FAIL "static:debug-on-credentials" "C8 violation: Debug derived near credential field. See /tmp/audit-debug-cred.log" @@ -197,29 +260,66 @@ off_chain() { emit PASS "static:debug-on-credentials" fi - # 4g. Cloud Run YAML should not reference upstream-API-key secrets (C10) - local cr_yaml - cr_yaml="$crate_dir/deploy" - if [[ -d "$cr_yaml" ]] && grep -rEn 'secretName:' "$cr_yaml" >/tmp/audit-secrets-mount.log 2>/dev/null; then - if grep -vE 'nexus-toolkit-config|nexus-allowed-leaders' /tmp/audit-secrets-mount.log >/tmp/audit-secrets-mount-bad.log; then - if [[ -s /tmp/audit-secrets-mount-bad.log ]]; then - emit FAIL "deploy:upstream-key-mounted" "C10 violation: unexpected secret in Cloud Run YAML. See /tmp/audit-secrets-mount-bad.log" + # 4f. tools.json present + shape (H7) — needs jq for the shape check; + # gracefully WARN if jq is not installed locally (CI has it). + if [[ ! -f "$crate_dir/tools.json" ]]; then + emit FAIL "tools-json:exists" "H7 violation: tools.json missing — shared discover workflow will skip this tool" + elif require jq; then + if jq -e '(.tool_name | type == "string") and (.command | type == "string") and ((.environment // {}) | type == "object")' "$crate_dir/tools.json" >/dev/null 2>&1; then + # tool_name must equal command must equal Cargo.toml's [[bin]].name + local tn cmd binname + tn=$(jq -r '.tool_name' "$crate_dir/tools.json") + cmd=$(jq -r '.command' "$crate_dir/tools.json") + binname=$(grep -A2 '^\[\[bin\]\]' "$crate_dir/Cargo.toml" 2>/dev/null | grep '^name = ' | head -1 | sed -E 's/name = "([^"]+)"/\1/') + if [[ -z "$binname" ]]; then + emit WARN "tools-json:name-match" "tool_name=$tn command=$cmd; no [[bin]] section in Cargo.toml yet (skill caller adds it)" + elif [[ "$tn" != "$cmd" ]] || [[ "$tn" != "$binname" ]]; then + emit FAIL "tools-json:name-match" "tool_name=$tn command=$cmd [[bin]].name=$binname — must all match" else - emit PASS "deploy:upstream-key-mounted" + emit PASS "tools-json:shape" fi else - emit PASS "deploy:upstream-key-mounted" + emit FAIL "tools-json:shape" "tools.json missing tool_name/command/environment" fi + + # 4g. C10: tools.json environment block must NOT contain anything + # secret-looking. The pipeline merges this into Cloud Run's env, which + # is project-readable. + if jq -e ' + [.environment // {} | to_entries[] | .key] + | map(test("(api[_-]?key|secret|token|bearer|password|private[_-]?key|access[_-]?token)"; "i")) + | any + ' "$crate_dir/tools.json" >/dev/null 2>&1; then + emit FAIL "tools-json:secret-in-env" "C10 violation: tools.json environment lists a secret-looking var — use Cloud Run secretKeyRef instead" + else + emit PASS "tools-json:secret-in-env" + fi + fi + + # 4h. build.rs present (required to thread TOOL_FQN_VERSION) + if [[ ! -f "$crate_dir/build.rs" ]]; then + emit WARN "build-rs:exists" "build.rs missing — TOOL_FQN_VERSION won't be threaded into FQNs" + else + emit PASS "build-rs:exists" fi - # 5. Output is enum (look for `enum Output` not `struct Output`) + # 4i. No legacy per-tool deploy plumbing left behind (post-#33 cleanup) + for legacy in "$crate_dir/deploy" "$crate_dir/paths.json" \ + ".github/workflows/deploy-${crate}-testnet.yml" \ + ".github/workflows/deploy-${crate}-mainnet.yml"; do + if [[ -e "$legacy" ]]; then + emit FAIL "legacy:per-tool-plumbing" "$legacy must be deleted — superseded by shared offchain-tools.* pipeline" + fi + done + + # 5. Output is enum (C2) if grep -rn 'enum Output' "$crate_dir/src" --include='*.rs' >/dev/null; then emit PASS "conform:output-enum" else - emit FAIL "conform:output-enum" "no enum Output found — Nexus requires top-level oneOf" + emit FAIL "conform:output-enum" "C2: no enum Output found — Nexus requires top-level oneOf" fi - # 6. deny_unknown_fields on every Input + # 6. deny_unknown_fields on every Input (H1) local input_files input_files="$(grep -rEln 'struct Input\b' "$crate_dir/src" --include='*.rs' || true)" if [[ -n "$input_files" ]]; then @@ -230,12 +330,20 @@ off_chain() { done <<< "$input_files" fi - # 7. description() override + # 7. description() override (M1) if grep -rn 'fn description' "$crate_dir/src" --include='*.rs' >/dev/null; then emit PASS "conform:description" else emit WARN "conform:description" "no description() override — /meta will show empty" fi + + # 8. FQN threaded through TOOL_FQN_VERSION (X1) + if grep -rn 'fqn!(concat!(' "$crate_dir/src" --include='*.rs' >/dev/null \ + && grep -rn 'env!("TOOL_FQN_VERSION")' "$crate_dir/src" --include='*.rs' >/dev/null; then + emit PASS "conform:fqn-versioned" + else + emit WARN "conform:fqn-versioned" "FQNs are not threaded through env!(\"TOOL_FQN_VERSION\") — version will stay at @1 forever" + fi } on_chain() { diff --git a/.claude/skills/nexus-tool-builder/scripts/new_tool.sh b/.claude/skills/nexus-tool-builder/scripts/new_tool.sh index 8d009f6..5667bf3 100755 --- a/.claude/skills/nexus-tool-builder/scripts/new_tool.sh +++ b/.claude/skills/nexus-tool-builder/scripts/new_tool.sh @@ -7,6 +7,13 @@ # new_tool.sh on-chain # on-chain Move tool # # Run from the nexus-tools repo root. +# +# For off-chain tools, this scaffolds at offchain/tools//, drops in +# tools.json + build.rs from templates, and gets the crate into the shared +# workspace (offchain/Cargo.toml declares members = ["tools/*"]) so the +# discover workflow (.github/workflows/offchain-tools.discover.yml) picks +# it up on the next push. No per-tool deploy/ dir, no per-tool workflow +# files — deploy is handled by the shared offchain-tools.* pipeline. set -euo pipefail @@ -22,18 +29,40 @@ fi REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" +SKILL_DIR=".claude/skills/nexus-tool-builder" +TEMPLATES_RUST="$SKILL_DIR/templates/rust" + case "$KIND" in off-chain) - if [[ -d "tools/$NAME" ]]; then - echo "tools/$NAME already exists; refusing to clobber" >&2 + DEST="offchain/tools/$NAME" + if [[ -d "$DEST" ]]; then + echo "$DEST already exists; refusing to clobber" >&2 exit 1 fi nexus tool new --name "$NAME" --template rust - mkdir -p tools - mv "$NAME" "tools/$NAME" - mkdir -p "tools/$NAME/deploy" - # Caller (the skill) will now render templates over tools/$NAME/. - echo "scaffolded tools/$NAME" + mkdir -p offchain/tools + mv "$NAME" "$DEST" + + # Drop in tools.json (required by the shared discover workflow). + # The skill caller may re-render with richer metadata after the fact. + sed "s/{{crate_name}}/$NAME/g" "$TEMPLATES_RUST/tools.json.tmpl" > "$DEST/tools.json" + + # Drop in build.rs (verbatim — the template is byte-identical to + # offchain/tools/memory-memwal/build.rs). + cp "$TEMPLATES_RUST/build.rs.tmpl" "$DEST/build.rs" + + cat >&2 <&2 ;; *) diff --git a/.claude/skills/nexus-tool-builder/scripts/verify.sh b/.claude/skills/nexus-tool-builder/scripts/verify.sh index c07c029..ac03879 100755 --- a/.claude/skills/nexus-tool-builder/scripts/verify.sh +++ b/.claude/skills/nexus-tool-builder/scripts/verify.sh @@ -5,6 +5,13 @@ # # Usage: # verify.sh +# +# Tools must be at offchain/tools//. The workspace lives at +# offchain/Cargo.toml. +# +# Paths are discovered at runtime by parsing the binary's --meta output +# (same mechanism .github/workflows/offchain-tools.prepare.yml uses), so +# this script needs no paths.json. set -euo pipefail @@ -12,20 +19,27 @@ CRATE="${1:?missing first arg: crate name}" REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" +if [[ ! -d "offchain/tools/$CRATE" ]]; then + echo "error: offchain/tools/$CRATE not found" >&2 + exit 1 +fi + +WORKSPACE_MANIFEST="offchain/Cargo.toml" + echo "==> cargo check" -cargo +stable check --package "$CRATE" +cargo +stable check --manifest-path "$WORKSPACE_MANIFEST" --package "$CRATE" echo "==> cargo clippy" -cargo +stable clippy --package "$CRATE" -- -D warnings +cargo +stable clippy --manifest-path "$WORKSPACE_MANIFEST" --package "$CRATE" -- -D warnings echo "==> cargo test" -cargo +stable test --package "$CRATE" +cargo +stable test --manifest-path "$WORKSPACE_MANIFEST" --package "$CRATE" if command -v just >/dev/null 2>&1; then NIGHTLY="$(just helpers::get-nightly-version 2>/dev/null || true)" if [[ -n "$NIGHTLY" ]]; then echo "==> cargo fmt --check (nightly $NIGHTLY)" - cargo "+$NIGHTLY" fmt --package "$CRATE" --check + cargo "+$NIGHTLY" fmt --manifest-path "$WORKSPACE_MANIFEST" --package "$CRATE" --check else echo "==> skipping fmt --check (nightly version not available)" fi @@ -33,9 +47,18 @@ else echo "==> skipping fmt --check (just not installed)" fi -echo "==> smoke test (cargo run + curl /health + /meta)" +# ---- Smoke test -------------------------------------------------------- +# A tool that reads a credential from env at startup will exit 1 if the +# env var is unset. Set a fake key so validate_credentials_at_startup +# passes (every per-crate convention is _API_KEY). +SERVICE_UPPER="$(echo "$CRATE" | tr '[:lower:]-' '[:upper:]_')_API_KEY" +echo "==> smoke test (cargo run + curl /meta + /health)" +echo " using ${SERVICE_UPPER}=sk_test_FAKE_FOR_VERIFY_ONLY (in process env)" + PORT=$(( RANDOM % 1000 + 38080 )) -BIND_ADDR="127.0.0.1:${PORT}" cargo +stable run --package "$CRATE" --release & +BIND_ADDR="127.0.0.1:${PORT}" \ + "$SERVICE_UPPER"=sk_test_FAKE_FOR_VERIFY_ONLY_$( head /dev/urandom | tr -dc A-Za-z0-9 | head -c 32 ) \ + cargo +stable run --manifest-path "$WORKSPACE_MANIFEST" --package "$CRATE" --release & PID=$! trap 'kill $PID 2>/dev/null || true' EXIT @@ -45,18 +68,29 @@ for i in $(seq 1 50); do sleep 0.2 done -PATHS_FILE="tools/$CRATE/paths.json" -if [[ -f "$PATHS_FILE" ]]; then - mapfile -t PATHS < <(jq -r '.[]' "$PATHS_FILE") +# Discover paths via /meta (works for single-path and multi-path crates). +# /meta on the root returns an array of { fqn, url, ... } across all +# registered tools; each entry's url path is the tool's mount point. +META_JSON="$(curl -fsS "http://127.0.0.1:${PORT}/meta" || true)" +if [[ -z "$META_JSON" ]]; then + echo "error: failed to fetch /meta from http://127.0.0.1:${PORT}/meta" >&2 + exit 1 +fi + +# Single-tool crates expose /meta at the root and return an object. +# Multi-tool crates may return an array. Handle both. +if echo "$META_JSON" | jq -e 'type == "array"' >/dev/null; then + mapfile -t PATHS < <(echo "$META_JSON" | jq -r '.[].url | capture("https?://[^/]+(?/.*)").path // "/"' | sort -u) else PATHS=("") fi for path in "${PATHS[@]}"; do - echo " checking ${path}/health" + display="${path:-/}" + echo " checking ${display}health" curl -fsS "http://127.0.0.1:${PORT}${path}/health" >/dev/null - echo " checking ${path}/meta" - curl -fsS "http://127.0.0.1:${PORT}${path}/meta" | jq -e .fqn >/dev/null + echo " checking ${display}meta" + curl -fsS "http://127.0.0.1:${PORT}${path}/meta" | jq -e '.fqn // .[0].fqn' >/dev/null done echo "==> ok" diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/Dockerfile.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/Dockerfile.tmpl deleted file mode 100644 index d3991a3..0000000 --- a/.claude/skills/nexus-tool-builder/templates/deploy/Dockerfile.tmpl +++ /dev/null @@ -1,27 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -# ---- Builder ---------------------------------------------------------------- -FROM rust:1.83-bookworm AS builder -WORKDIR /build - -# Copy workspace manifests first so dependency builds cache cleanly. -COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ -COPY tools/{{crate_name}}/Cargo.toml tools/{{crate_name}}/Cargo.toml -# Workspace `members = ["tools/*"]` requires every sibling to exist for the -# resolver. Easiest: copy the whole workspace. -COPY tools tools -COPY helpers helpers - -RUN cargo +stable build --release --package {{crate_name}} - -# ---- Runtime ---------------------------------------------------------------- -FROM gcr.io/distroless/cc-debian12:nonroot AS runtime -WORKDIR /app - -COPY --from=builder /build/target/release/{{crate_name}} /app/{{crate_name}} - -ENV BIND_ADDR=0.0.0.0:8080 -EXPOSE 8080 - -USER nonroot -ENTRYPOINT ["/app/{{crate_name}}"] diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/allowed_leaders.json.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/allowed_leaders.json.tmpl deleted file mode 100644 index 71f6efd..0000000 --- a/.claude/skills/nexus-tool-builder/templates/deploy/allowed_leaders.json.tmpl +++ /dev/null @@ -1,9 +0,0 @@ -{ - "_comment": "Placeholder. The real file is mounted via Secret Manager at /etc/nexus/allowed_leaders/.json. Populate via `nexus key list-leaders --network ` and commit only the ID/key pairs to the secret, not this file.", - "leaders": [ - { - "leader_id": "0x0000000000000000000000000000000000000000000000000000000000000000", - "public_key_ed25519_b64": "REPLACE_ME" - } - ] -} diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.mainnet.yaml.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.mainnet.yaml.tmpl deleted file mode 100644 index b627277..0000000 --- a/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.mainnet.yaml.tmpl +++ /dev/null @@ -1,61 +0,0 @@ -# Credential model: this service holds NO upstream API credentials. -# The only secrets mounted here are: -# - nexus-toolkit-config-mainnet- (Tool's own Ed25519 signing key, tool_kid) -# - nexus-allowed-leaders-mainnet (public keys, not secret) -# Upstream API keys (Stripe, OpenAI, etc.) come in via the per-request -# `Input` struct, sourced by the Leader at DAG-execution time. Adding -# any other secretKeyRef fails the auditor's C10 check. -apiVersion: serving.knative.dev/v1 -kind: Service -metadata: - name: "{{crate_name}}-mainnet" - labels: - network: mainnet - tool-fqn: "{{fqn_label}}" - annotations: - run.googleapis.com/launch-stage: GA -spec: - template: - metadata: - annotations: - autoscaling.knative.dev/minScale: "1" - autoscaling.knative.dev/maxScale: "20" - run.googleapis.com/execution-environment: gen2 - spec: - serviceAccountName: "nexus-tools-mainnet@${PROJECT_ID}.iam.gserviceaccount.com" - timeoutSeconds: 30 - containerConcurrency: 80 - containers: - - name: tool - image: "${REGISTRY}/{{crate_name}}:${SHA}" - ports: - - name: http1 - containerPort: 8080 - env: - - name: BIND_ADDR - value: 0.0.0.0:8080 - - name: NEXUS_TOOLKIT_CONFIG_PATH - value: /etc/nexus/toolkit.json - - name: NEXUS_NETWORK - value: mainnet - volumeMounts: - - name: nexus-config - mountPath: /etc/nexus - readOnly: true - - name: allowed-leaders - mountPath: /etc/nexus/allowed_leaders - readOnly: true - resources: - limits: - cpu: "2" - memory: 1Gi - volumes: - - name: nexus-config - secret: - secretName: "nexus-toolkit-config-mainnet-{{crate_name}}" - - name: allowed-leaders - secret: - secretName: nexus-allowed-leaders-mainnet - traffic: - - percent: 100 - latestRevision: true diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.testnet.yaml.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.testnet.yaml.tmpl deleted file mode 100644 index fbaffbf..0000000 --- a/.claude/skills/nexus-tool-builder/templates/deploy/cloud-run.testnet.yaml.tmpl +++ /dev/null @@ -1,61 +0,0 @@ -# Credential model: this service holds NO upstream API credentials. -# The only secrets mounted here are: -# - nexus-toolkit-config-testnet- (Tool's own Ed25519 signing key, tool_kid) -# - nexus-allowed-leaders-testnet (public keys, not secret) -# Upstream API keys (Stripe, OpenAI, etc.) come in via the per-request -# `Input` struct, sourced by the Leader at DAG-execution time. Adding -# any other secretKeyRef fails the auditor's C10 check. -apiVersion: serving.knative.dev/v1 -kind: Service -metadata: - name: "{{crate_name}}-testnet" - labels: - network: testnet - tool-fqn: "{{fqn_label}}" - annotations: - run.googleapis.com/launch-stage: GA -spec: - template: - metadata: - annotations: - autoscaling.knative.dev/minScale: "0" - autoscaling.knative.dev/maxScale: "5" - run.googleapis.com/execution-environment: gen2 - spec: - serviceAccountName: "nexus-tools-testnet@${PROJECT_ID}.iam.gserviceaccount.com" - timeoutSeconds: 30 - containerConcurrency: 80 - containers: - - name: tool - image: "${REGISTRY}/{{crate_name}}:${SHA}" - ports: - - name: http1 - containerPort: 8080 - env: - - name: BIND_ADDR - value: 0.0.0.0:8080 - - name: NEXUS_TOOLKIT_CONFIG_PATH - value: /etc/nexus/toolkit.json - - name: NEXUS_NETWORK - value: testnet - volumeMounts: - - name: nexus-config - mountPath: /etc/nexus - readOnly: true - - name: allowed-leaders - mountPath: /etc/nexus/allowed_leaders - readOnly: true - resources: - limits: - cpu: "1" - memory: 512Mi - volumes: - - name: nexus-config - secret: - secretName: "nexus-toolkit-config-testnet-{{crate_name}}" - - name: allowed-leaders - secret: - secretName: nexus-allowed-leaders-testnet - traffic: - - percent: 100 - latestRevision: true diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-mainnet.yml.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-mainnet.yml.tmpl deleted file mode 100644 index e5fc28a..0000000 --- a/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-mainnet.yml.tmpl +++ /dev/null @@ -1,89 +0,0 @@ -name: deploy-{{crate_name}}-mainnet - -on: - push: - tags: - - "v{{crate_name}}-*" # e.g. v{{crate_name}}-1.0.0 - -permissions: - contents: read - id-token: write - -env: - PROJECT_ID: talus-tools-mainnet - REGION: us-central1 - REPO: nexus-tools - CRATE: "{{crate_name}}" - SERVICE: "{{crate_name}}-mainnet" - -jobs: - guard: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Require testnet success on this commit - uses: actions/github-script@v7 - with: - script: | - const { owner, repo } = context.repo; - const ref = context.sha; - const { data } = await github.rest.checks.listForRef({ - owner, repo, ref, check_name: 'build-deploy-register' - }); - const ok = data.check_runs.some(c => - c.name === 'build-deploy-register' && c.conclusion === 'success' - ); - if (!ok) { - core.setFailed('Testnet deploy must succeed on this commit before mainnet.'); - } - - build-deploy-register: - needs: guard - runs-on: ubuntu-latest - environment: mainnet # requires reviewer approval (configured in GitHub UI) - steps: - - uses: actions/checkout@v4 - - - id: auth - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER_MAINNET }} - service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com - - - uses: google-github-actions/setup-gcloud@v2 - - - name: Configure Docker - run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet - - - name: Build and push image - env: - SHA: ${{ github.sha }} - run: | - IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" - docker build -f tools/${CRATE}/deploy/Dockerfile -t "$IMAGE" . - docker push "$IMAGE" - - - name: Deploy to Cloud Run - env: - SHA: ${{ github.sha }} - run: | - IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" - envsubst < tools/${CRATE}/deploy/cloud-run.mainnet.yaml > /tmp/svc.yaml - gcloud run services replace /tmp/svc.yaml --region "$REGION" --quiet - URL=$(gcloud run services describe "$SERVICE" --region "$REGION" --format='value(status.url)') - echo "URL=$URL" >> "$GITHUB_ENV" - - - name: Smoke test - run: | - for p in $(jq -r '.[]' tools/${CRATE}/paths.json); do - curl -fsS "${URL}${p}/health" - curl -fsS "${URL}${p}/meta" | jq -e .fqn >/dev/null - done - - - name: Install Nexus CLI - run: | - curl -fsSL https://raw.githubusercontent.com/Talus-Network/homebrew-tap/main/install.sh | bash - echo "$HOME/.nexus/bin" >> "$GITHUB_PATH" - - - name: Register / update tool on Nexus mainnet - run: bash tools/${CRATE}/deploy/register.sh "$URL" mainnet diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-testnet.yml.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-testnet.yml.tmpl deleted file mode 100644 index 3f3b403..0000000 --- a/.claude/skills/nexus-tool-builder/templates/deploy/github-workflow-testnet.yml.tmpl +++ /dev/null @@ -1,70 +0,0 @@ -name: deploy-{{crate_name}}-testnet - -on: - push: - branches: [main] - paths: - - "tools/{{crate_name}}/**" - - ".github/workflows/deploy-{{crate_name}}-testnet.yml" - -permissions: - contents: read - id-token: write # for Workload Identity Federation - -env: - PROJECT_ID: talus-tools-testnet - REGION: us-central1 - REPO: nexus-tools - CRATE: "{{crate_name}}" - SERVICE: "{{crate_name}}-testnet" - -jobs: - build-deploy-register: - runs-on: ubuntu-latest - environment: testnet - steps: - - uses: actions/checkout@v4 - - - id: auth - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER_TESTNET }} - service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com - - - uses: google-github-actions/setup-gcloud@v2 - - - name: Configure Docker - run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet - - - name: Build and push image - env: - SHA: ${{ github.sha }} - run: | - IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" - docker build -f tools/${CRATE}/deploy/Dockerfile -t "$IMAGE" . - docker push "$IMAGE" - - - name: Deploy to Cloud Run - env: - SHA: ${{ github.sha }} - run: | - IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" - envsubst < tools/${CRATE}/deploy/cloud-run.testnet.yaml > /tmp/svc.yaml - gcloud run services replace /tmp/svc.yaml --region "$REGION" --quiet - URL=$(gcloud run services describe "$SERVICE" --region "$REGION" --format='value(status.url)') - echo "URL=$URL" >> "$GITHUB_ENV" - - - name: Smoke test - run: | - for p in $(jq -r '.[]' tools/${CRATE}/paths.json); do - curl -fsS "${URL}${p}/health" - curl -fsS "${URL}${p}/meta" | jq -e .fqn >/dev/null - done - - - name: Install Nexus CLI - run: | - curl -fsSL https://raw.githubusercontent.com/Talus-Network/homebrew-tap/main/install.sh | bash - echo "$HOME/.nexus/bin" >> "$GITHUB_PATH" - - - name: Register / update tool on Nexus testnet - run: bash tools/${CRATE}/deploy/register.sh "$URL" testnet diff --git a/.claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl b/.claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl deleted file mode 100755 index 088607b..0000000 --- a/.claude/skills/nexus-tool-builder/templates/deploy/register.sh.tmpl +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# Idempotent registration of {{crate_name}} with Nexus. -# -# Usage: -# register.sh -# -# Reads tool FQNs from `${URL}/meta` (one per path defined in the crate), -# then registers or updates each FQN against Nexus on the chosen network. - -set -euo pipefail - -URL="${1:?missing first arg: tool URL}" -NETWORK="${2:?missing second arg: testnet|mainnet}" - -case "$NETWORK" in - testnet|mainnet) ;; - *) echo "network must be 'testnet' or 'mainnet'"; exit 2 ;; -esac - -# Discover every NexusTool path the binary serves. -# `bootstrap!` mounts //meta for each — but we don't know paths a priori. -# Convention: the skill generates a `tools//paths.json` array of paths, -# emitted at scaffold time. -PATHS_FILE="$(dirname "$0")/../paths.json" -if [[ ! -f "$PATHS_FILE" ]]; then - echo "expected $PATHS_FILE to enumerate tool paths" - exit 2 -fi - -mapfile -t PATHS < <(jq -r '.[]' "$PATHS_FILE") - -for path in "${PATHS[@]}"; do - meta_url="${URL%/}${path}/meta" - meta="$(curl -fsS "$meta_url")" - fqn="$(echo "$meta" | jq -r .fqn)" - description="$(echo "$meta" | jq -r .description)" - - echo "registering $fqn at $URL$path on $NETWORK" - - if nexus tool list --network "$NETWORK" | grep -q "$fqn"; then - nexus tool update offchain \ - --network "$NETWORK" \ - --tool-fqn "$fqn" \ - --url "${URL%/}${path}" - else - nexus tool register offchain \ - --network "$NETWORK" \ - --tool-fqn "$fqn" \ - --url "${URL%/}${path}" \ - --description "$description" - fi -done diff --git a/.claude/skills/nexus-tool-builder/templates/rust/Cargo.toml.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/Cargo.toml.tmpl index c0a0195..69a4827 100644 --- a/.claude/skills/nexus-tool-builder/templates/rust/Cargo.toml.tmpl +++ b/.claude/skills/nexus-tool-builder/templates/rust/Cargo.toml.tmpl @@ -12,14 +12,23 @@ authors.workspace = true keywords.workspace = true categories.workspace = true +[[bin]] +name = "{{crate_name}}" +path = "src/main.rs" + [dependencies] +anyhow.workspace = true chrono.workspace = true -thiserror.workspace = true -tokio.workspace = true +dotenvy.workspace = true +env_logger.workspace = true +log.workspace = true reqwest = { workspace = true, features = ["json"] } -serde_json.workspace = true -serde.workspace = true schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +zeroize.workspace = true # === Nexus deps === nexus-toolkit.workspace = true @@ -27,3 +36,7 @@ nexus-sdk.workspace = true [dev-dependencies] mockito.workspace = true + +[build-dependencies] +serde_json.workspace = true +toml = "0.8" diff --git a/.claude/skills/nexus-tool-builder/templates/rust/build.rs.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/build.rs.tmpl new file mode 100644 index 0000000..07c24d8 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/rust/build.rs.tmpl @@ -0,0 +1,50 @@ +use std::{env, fs, path::PathBuf}; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let tools_json_path = manifest_dir.join("tools.json"); + let cargo_toml_path = manifest_dir.join("Cargo.toml"); + + println!("cargo::rerun-if-changed={}", tools_json_path.display()); + println!("cargo::rerun-if-changed={}", cargo_toml_path.display()); + + let tools_json: serde_json::Value = serde_json::from_str( + &fs::read_to_string(&tools_json_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", tools_json_path.display())), + ) + .expect("tools.json must be valid JSON"); + + let command = tools_json["command"] + .as_str() + .expect("tools.json must have command"); + + let cargo_toml: toml::Value = toml::from_str( + &fs::read_to_string(&cargo_toml_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", cargo_toml_path.display())), + ) + .expect("Cargo.toml must be valid TOML"); + + let bin_name = cargo_toml + .get("bin") + .and_then(|b| b.as_array()) + .and_then(|bins| bins.first()) + .and_then(|b| b.get("name")) + .and_then(|n| n.as_str()); + + match bin_name { + Some(name) if name == command => {} + Some(name) => panic!( + "Binary name mismatch: Cargo.toml [[bin]] name = \"{name}\" \ + but tools.json command = \"{command}\". They must match." + ), + None => panic!( + "Cargo.toml has no [[bin]] section but tools.json specifies \ + command = \"{command}\". Add: [[bin]]\nname = \"{command}\"" + ), + } + + // FQN version is set by CI via Docker build-arg; defaults to "1" locally. + let version = env::var("TOOL_FQN_VERSION").unwrap_or_else(|_| "1".to_string()); + println!("cargo:rustc-env=TOOL_FQN_VERSION={version}"); + println!("cargo:rerun-if-env-changed=TOOL_FQN_VERSION"); +} diff --git a/.claude/skills/nexus-tool-builder/templates/rust/client.rs.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/client.rs.tmpl index e6fe753..dae89fc 100644 --- a/.claude/skills/nexus-tool-builder/templates/rust/client.rs.tmpl +++ b/.claude/skills/nexus-tool-builder/templates/rust/client.rs.tmpl @@ -5,14 +5,20 @@ //! //! ## Credential model //! -//! **This client holds NO upstream API credentials.** Per the -//! nexus-tools repo convention (see -//! `tools/llm-openai-chat-completion`, `tools/social-twitter`), every -//! credential is passed in by the caller on each request, sourced from -//! the `Input` struct of the invoking `NexusTool`. +//! **Upstream credentials come from the process environment, NOT from +//! tool inputs.** Tool inputs flow through the Nexus DAG on Sui as +//! plaintext — anything you put on `Input` is effectively published. //! -//! The endpoint code calls `client.with_auth(&input.api_key)` (or -//! equivalent) before each request — never `std::env::var(...)`. +//! - `{{SERVICE_UPPER}}_API_KEY` is read once at startup by +//! [`from_env`] and wrapped in [`zeroize::Zeroizing`] so heap +//! buffers are wiped on drop. +//! - The struct hand-implements `Debug` to print `` for the +//! bearer — never `#[derive(Debug)]`. +//! - The bearer is set once at construction; there is no per-request +//! `.with_auth(...)` builder. Each `NexusTool::new()` call constructs a +//! fresh `Client` via `from_env()`; cloning is cheap (Arc). +//! +//! Canonical reference: `offchain/tools/memory-memwal/src/client.rs`. //! //! Auth model selected for this crate: {{auth_model}}. @@ -23,46 +29,175 @@ use { }, reqwest::{Client, RequestBuilder}, serde::{de::DeserializeOwned, Serialize}, - std::sync::Arc, + std::sync::{Arc, Once, OnceLock}, + zeroize::Zeroizing, }; +/// Env var holding the upstream API credential. Set per Cloud Run service +/// via Secret Manager `secretKeyRef` (operator-configured, NOT in +/// `tools.json`). +pub(crate) const ENV_API_KEY: &str = "{{SERVICE_UPPER}}_API_KEY"; + +/// One-shot guard so the "missing key" warning fires once even though +/// `from_env` runs once per registered tool. +static WARN_MISSING_KEY: Once = Once::new(); + +/// Process-wide HTTP client shared across every `{{ServicePascal}}Client` +/// so the connection pool, TLS session cache, and HTTP/2 multiplexing +/// survive across `invoke` calls. `reqwest::Client` clone is a cheap Arc bump. +static SHARED_HTTP: OnceLock = OnceLock::new(); + +fn shared_http() -> Client { + SHARED_HTTP + .get_or_init(|| { + Client::builder() + .user_agent("nexus-sdk-{{crate_name}}/1.0") + .build() + .expect("Failed to create HTTP client") + }) + .clone() +} + +/// Load `/.env` if present. Cwd-only (no parent walk) so a planted +/// `.env` above the binary's cwd cannot influence the process. Existing +/// exports always win. MUST be called before the tokio runtime is built — +/// `set_var` is unsound from a multi-threaded process. +pub(crate) fn load_dotenv_if_present() { + let candidate = match std::env::current_dir() { + Ok(d) => d.join(".env"), + Err(e) => { + log::warn!("could not read cwd while looking for .env: {e}"); + return; + } + }; + if !candidate.is_file() { + return; + } + match dotenvy::from_path(&candidate) { + Ok(()) => log::info!("loaded env vars from {}", candidate.display()), + Err(e) => log::warn!("failed to load {}: {e}", candidate.display()), + } +} + +/// Classification of the API-key env var. Hand-written `Debug` redacts the +/// secret — never `derive` Debug on this type. +enum KeyValidation { + Ok(Zeroizing), + Missing, + Invalid(String), +} + +impl std::fmt::Debug for KeyValidation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ok(_) => write!(f, "Ok()"), + Self::Missing => write!(f, "Missing"), + Self::Invalid(reason) => f.debug_tuple("Invalid").field(reason).finish(), + } + } +} + +/// Pure classifier — no I/O, no exits, deterministic. +fn classify_key(value: Option<&str>) -> KeyValidation { + let raw = match value { + Some(s) if !s.is_empty() => s, + _ => return KeyValidation::Missing, + }; + // {{ServicePascal}}-specific shape check. Tighten per upstream's key format. + // Example for Stripe: must start with `sk_test_` or `sk_live_`. + if raw.len() < 16 { + return KeyValidation::Invalid(format!( + "is set but is too short ({} chars). Expected ≥16-character API key.", + raw.len() + )); + } + KeyValidation::Ok(Zeroizing::new(raw.to_string())) +} + +/// Eager startup-time key validation. Without this, a malformed key would +/// only surface on the first `/invoke`. Called from `main`. +pub(crate) fn validate_credentials_at_startup() -> Result<(), String> { + read_validated_api_key().map(|_| ()) +} + +fn read_validated_api_key() -> Result>, String> { + let raw = std::env::var(ENV_API_KEY).ok().map(Zeroizing::new); + match classify_key(raw.as_deref().map(|z| z.as_str())) { + KeyValidation::Ok(k) => Ok(Some(k)), + KeyValidation::Missing => { + WARN_MISSING_KEY.call_once(|| { + log::warn!( + "{ENV_API_KEY} is not set — every invoke will fail \ + authentication upstream until this env var is exported." + ); + }); + Ok(None) + } + KeyValidation::Invalid(reason) => Err(reason), + } +} + #[derive(Clone)] pub struct {{ServicePascal}}Client { client: Arc, base_url: String, - /// Optional per-call bearer credential. Set via `.with_auth(...)`. - /// Held only for the duration of one invocation; never persisted. - bearer: Option, - /// Optional per-call idempotency key. Set via `.with_idempotency(...)`. + /// API credential read once at startup from `ENV_API_KEY`. Wrapped in + /// `Zeroizing` so heap buffers are wiped on drop. `None` if the env + /// var was missing or malformed — `invoke()` calls will fail upstream + /// with the bare 401, which is the right signal to the operator. + bearer: Option>, + /// Per-request idempotency key (Stripe-style writes). Set via + /// `.with_idempotency(...)` on the cheap-cloned client. idempotency_key: Option, } -impl {{ServicePascal}}Client { - pub fn new(base_url: Option<&str>) -> Self { - let base_url = base_url.unwrap_or({{SERVICE_UPPER}}_API_BASE).to_string(); +// Hand-written Debug — NEVER derive on a type holding a credential. +impl std::fmt::Debug for {{ServicePascal}}Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("{{ServicePascal}}Client") + .field("base_url", &self.base_url) + .field("bearer", &if self.bearer.is_some() { "" } else { "" }) + .field("idempotency_key", &self.idempotency_key) + .finish() + } +} - let client = Client::builder() - .user_agent("nexus-sdk-{{crate_name}}/1.0") - .build() - .expect("Failed to create HTTP client"); +impl {{ServicePascal}}Client { + /// Production constructor: reads `ENV_API_KEY` from the process env + /// and wraps it in `Zeroizing`. A missing/malformed key logs but does + /// not error here — `main`'s startup validation is the fail-fast site. + pub fn from_env() -> Result { + let bearer = match read_validated_api_key() { + Ok(Some(k)) => Some(k), + Ok(None) => None, + Err(reason) => { + log::error!("{ENV_API_KEY} {reason}"); + None + } + }; + Ok(Self { + client: Arc::new(shared_http()), + base_url: {{SERVICE_UPPER}}_API_BASE.to_string(), + bearer, + idempotency_key: None, + }) + } + /// Test-only constructor. Accepts a base URL (HTTP loopback for + /// mockito) and a bearer string directly, bypassing env validation. + #[cfg(test)] + pub fn for_testing(base_url: &str, bearer: &str) -> Self { Self { - client: Arc::new(client), - base_url, - bearer: None, + client: Arc::new(Client::builder() + .user_agent("nexus-sdk-{{crate_name}}/1.0") + .build() + .expect("failed to build test HTTP client")), + base_url: base_url.to_string(), + bearer: Some(Zeroizing::new(bearer.to_string())), idempotency_key: None, } } - /// Attach a bearer credential for the next request. The credential is - /// passed in by the DAG via the `Input` struct — this client never - /// reads it from env / disk. - #[must_use] - pub fn with_auth(mut self, bearer: &str) -> Self { - self.bearer = Some(bearer.to_string()); - self - } - /// Attach an `Idempotency-Key` header for the next request. Required /// by Stripe-style APIs on writes; harmless on others. #[must_use] @@ -119,7 +254,7 @@ impl {{ServicePascal}}Client { fn apply_headers(&self, mut req: RequestBuilder) -> RequestBuilder { if let Some(ref bearer) = self.bearer { - req = req.bearer_auth(bearer); + req = req.bearer_auth(bearer.as_str()); } if let Some(ref key) = self.idempotency_key { req = req.header("Idempotency-Key", key); diff --git a/.claude/skills/nexus-tool-builder/templates/rust/endpoint.rs.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/endpoint.rs.tmpl index dee4a5e..0fe60f7 100644 --- a/.claude/skills/nexus-tool-builder/templates/rust/endpoint.rs.tmpl +++ b/.claude/skills/nexus-tool-builder/templates/rust/endpoint.rs.tmpl @@ -2,23 +2,25 @@ //! //! {{endpoint_description}} //! +//! ## Credential contract +//! +//! Upstream credentials come from the process environment +//! (`{{SERVICE_UPPER}}_API_KEY`), read once at startup by +//! `{{ServicePascal}}Client::from_env`. They are NEVER fields on `Input` +//! — tool inputs flow through the Nexus DAG on Sui as plaintext, so any +//! credential-shaped Input field is effectively published on-chain. +//! //! ## Statelessness contract //! //! Per the `nexus-toolkit` contract, `new()` is called on every request. -//! This tool MUST be stateless across invocations: +//! This tool MUST be stateless w.r.t. request data: //! -//! - No `static` / `lazy_static` / `OnceLock` storing per-request data. -//! - No `Mutex` / `RwLock` / `Cell` / `RefCell` accumulating state. +//! - No mutable per-request state in `static` / `lazy_static` / +//! `Mutex` / `RwLock` / `Cell` / `RefCell`. //! - No on-disk writes outside `#[cfg(test)]`. -//! - The struct's only fields are configuration / clones of a stateless -//! `reqwest::Client` pool. Same input + same `Input.api_key` ⇒ -//! identical upstream call. -//! -//! ## Credential contract -//! -//! Upstream API credentials live ONLY in the `Input` struct, passed by -//! the DAG / Leader per invocation. This tool never reads credentials -//! from env vars, files, or any persistent store. +//! - The struct's only field is a cheap-to-clone `{{ServicePascal}}Client` +//! (shared HTTP pool + Arc-wrapped bearer). Same input ⇒ identical +//! upstream call. use { crate::{ @@ -31,19 +33,19 @@ use { serde::{Deserialize, Serialize}, }; -#[derive(Deserialize, JsonSchema)] +#[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { - /// {{ServicePascal}} secret key passed in by the DAG / Leader. - /// NEVER set this as a `default_values` entry in the on-chain DAG — - /// values committed to Sui are public and permanent. The Leader - /// supplies this at execution time from its secret store. - pub api_key: String, - + // **NO credential-shaped fields.** Do not add `api_key`, `bearer`, + // `secret`, `token`, `password`, `private_key`, `access_token`, + // `consumer_secret`, or `client_secret`. The auditor's C1 check + // refuses to mark the tool ready if it finds one. Credentials come + // from `{{SERVICE_UPPER}}_API_KEY` env var. {{#if is_write}} /// Optional `Idempotency-Key` header. Generate a UUID per logical /// retry-bucket in your DAG; reusing the same key returns the same - /// upstream response without double-charging. + /// upstream response without double-charging. This is NOT a credential + /// — it's per-call dedup data. pub idempotency_key: Option, {{/if}} @@ -53,8 +55,7 @@ pub(crate) struct Input { // - snake_case // - optional fields use Option // - split paired fields (prompt + context) into separate ports - // - never derive Debug on Input — it would log api_key. Use a - // manual Debug impl that redacts credential fields if needed. + // - Debug is fine to derive — there are no credentials in Input /// {{example_input_doc}} pub {{example_input_name}}: String, } @@ -75,8 +76,9 @@ pub(crate) enum Output { }, } -/// Stateless: holds only a stateless connection pool. `new()` may be -/// called per request without performance penalty. +/// Stateless: holds only a cheap-to-clone `{{ServicePascal}}Client`. +/// `new()` constructs from env every request; the shared HTTP pool and +/// startup-validated bearer are cached. pub(crate) struct {{EndpointPascal}} { client: {{ServicePascal}}Client, } @@ -87,12 +89,16 @@ impl NexusTool for {{EndpointPascal}} { async fn new() -> Self { Self { - client: {{ServicePascal}}Client::new(None), + client: {{ServicePascal}}Client::from_env().unwrap_or_else(|e| { + log::error!("{{crate_name}} configuration invalid: {e}"); + panic!("{{crate_name}} configuration invalid: {e}"); + }), } } fn fqn() -> ToolFqn { - fqn!("{{fqn}}") + // CI bumps TOOL_FQN_VERSION on every source change (subtree git hash). + fqn!(concat!("{{fqn_no_version}}@", env!("TOOL_FQN_VERSION"))) } fn path() -> &'static str { @@ -108,15 +114,15 @@ impl NexusTool for {{EndpointPascal}} { } async fn invoke(&self, input: Self::Input) -> Self::Output { - // Build a per-call authed client; the credential lives only in - // this function's scope and goes out of scope when invoke returns. - let client = self.client.clone().with_auth(&input.api_key); - + // Client is already authed from startup env var. Cheap clone for + // per-request mutations (idempotency key). {{#if is_write}} let client = match input.idempotency_key { - Some(ref k) => client.with_idempotency(k), - None => client, + Some(ref k) => self.client.clone().with_idempotency(k), + None => self.client.clone(), }; + {{else}} + let client = &self.client; {{/if}} let endpoint = format!("{{endpoint_path_template}}", input.{{example_input_name}}); @@ -143,15 +149,14 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, {{EndpointPascal}}) { let server = Server::new_async().await; - let client = {{ServicePascal}}Client::new(Some(&server.url())); + // Bypass env-var validation: inject a test bearer directly. + let client = {{ServicePascal}}Client::for_testing(&server.url(), "sk_test_FAKE_FOR_TESTS_ONLY"); let tool = {{EndpointPascal}} { client }; (server, tool) } fn test_input({{example_input_name}}: &str) -> Input { Input { - // Test-mode key only — fixtures must never contain `sk_live_…`. - api_key: "sk_test_FAKE_FOR_TESTS_ONLY".to_string(), {{#if is_write}} idempotency_key: None, {{/if}} diff --git a/.claude/skills/nexus-tool-builder/templates/rust/env.example.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/env.example.tmpl new file mode 100644 index 0000000..e9aa715 --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/rust/env.example.tmpl @@ -0,0 +1,12 @@ +# {{crate_name}} — required environment variables. +# +# Copy to `.env` for local dev (this file MUST stay in source control; +# `.env` MUST be gitignored). In production these come from Cloud Run +# `secretKeyRef` bindings to GCP Secret Manager (operator-configured). +# +# The tool fails to start without {{SERVICE_UPPER}}_API_KEY set. + +{{SERVICE_UPPER}}_API_KEY=sk_test_replace_with_real_key + +# Optional. Defaults to "info". +RUST_LOG=info diff --git a/.claude/skills/nexus-tool-builder/templates/rust/main.rs.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/main.rs.tmpl index b2b7be5..083c7b6 100644 --- a/.claude/skills/nexus-tool-builder/templates/rust/main.rs.tmpl +++ b/.claude/skills/nexus-tool-builder/templates/rust/main.rs.tmpl @@ -7,10 +7,39 @@ mod {{service_snake}}_client; mod error; mod tools; -#[tokio::main] -async fn main() { - bootstrap!([ - // Add one entry per endpoint, e.g.: - // tools::{{endpoint_snake}}::{{EndpointPascal}}, - ]); +fn main() { + // Install env_logger before anything else so dotenv/credential paths + // emit through `log::*`; `bootstrap!`'s own `try_init()` becomes a no-op. + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .init(); + + // `--meta` is an introspection mode that emits the tool's FQN/URL/ + // schema list without standing up the runtime. CI's prepare step + // invokes it from a fresh image with no env, so it must not require + // runtime credentials. `nexus_toolkit::bootstrap!` handles --meta + // and exits before any HTTP call is made. + let meta_only = std::env::args().any(|a| a == "--meta"); + + if !meta_only { + // dotenv + credential validation run single-threaded — `set_var` + // is unsound from a multi-threaded process. `main` is the only + // exit site. + {{service_snake}}_client::load_dotenv_if_present(); + if let Err(reason) = {{service_snake}}_client::validate_credentials_at_startup() { + log::error!("{} {reason}", {{service_snake}}_client::ENV_API_KEY); + std::process::exit(1); + } + } + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime") + .block_on(async { + bootstrap!([ + // Add one entry per endpoint, e.g.: + // tools::{{endpoint_snake}}::{{EndpointPascal}}, + ]); + }); } diff --git a/.claude/skills/nexus-tool-builder/templates/rust/tools.json.tmpl b/.claude/skills/nexus-tool-builder/templates/rust/tools.json.tmpl new file mode 100644 index 0000000..9bbb71c --- /dev/null +++ b/.claude/skills/nexus-tool-builder/templates/rust/tools.json.tmpl @@ -0,0 +1,7 @@ +{ + "tool_name": "{{crate_name}}", + "command": "{{crate_name}}", + "environment": { + "RUST_LOG": "info" + } +}