Skip to main content

sdkt_core/
registry.rs

1//! Agent-facing capability registry for the `sdkt` CLI.
2//!
3//! This is metadata **about commands that already exist** — it executes
4//! nothing and adds no new CLI surface. It exists so an agent or orchestrator
5//! can discover what the toolkit can do, what each call requires, and how
6//! dangerous it is, without scraping `--help` output.
7//!
8//! Shape of the world it describes:
9//!
10//! ```text
11//! Agent → Tool Registry → SDKT CLI → deterministic evidence
12//! ```
13//!
14//! The registry is the contract layer; the CLI remains the source of truth
15//! for behavior. If the two ever disagree, the CLI wins and this table is the
16//! bug.
17//!
18//! # Evidence discipline
19//!
20//! Every entry carries an [`Evidence`] level. Entries are only marked
21//! `Verified*` when the command was actually executed and its exit code and
22//! stdout shape were observed (local artifacts, or live read-only calls
23//! against Testnet). Anything not observed is `Unverified` and MUST carry a
24//! `notes` string saying why — the registry never asserts a capability it has
25//! not seen work, and never guesses a mutating command's outcome.
26//!
27//! # Adding a capability
28//!
29//! 1. Confirm the command exists: `sdkt <cmd> --help`, and run it once in the
30//!    cheapest safe mode (offline fixture, or read-only against Testnet).
31//! 2. Append a `Capability` to [`capabilities()`] using struct-update syntax
32//!    from [`Capability::BASE`] so only the fields that differ are spelled out:
33//!    ```text
34//!    Capability {
35//!        id: "family.action",          // lowercase, dotted, unique
36//!        command: &["family", "action"],
37//!        summary: "one line, what it returns",
38//!        required_args: &["<arg>"],
39//!        formats: PRETTY_JSON,
40//!        network: NetworkRequirement::Both,
41//!        evidence: Evidence::VerifiedLocal,   // or VerifiedTestnet
42//!        ..Capability::BASE
43//!    }
44//!    ```
45//! 3. Rules that the tests enforce — do not work around them:
46//!    - IDs are unique and non-empty; `command`, `summary` are non-empty.
47//!    - `safety: Mutating` ⇒ `requires_confirmation: true`.
48//!    - Read-only commands must NOT set `requires_confirmation`.
49//!    - `formats.json: true` only when the real command accepts
50//!      `--format json` and emits a parseable document.
51//!    - `evidence: Unverified` ⇒ `notes` must explain what is missing.
52//!    - Exit codes are the CLI-wide convention: 0 success, 1 runtime/
53//!      verdict failure, 2 usage error. Set `verdict_can_fail` only for
54//!      commands that exit 1 while still producing a valid report.
55//! 4. Add the id to the pinned lists in the tests that cross-check against
56//!    observed behavior (`json_format_flags_match_cli`,
57//!    `verdict_gated_commands_are_marked`).
58//! 5. `cargo test -p sdkt-core` must stay green. Bump
59//!    [`SCHEMA_VERSION`] only on a breaking change to this metadata format.
60
61use serde::{Deserialize, Serialize};
62
63/// Schema version of the exported document. Consumers may pin against it.
64pub const SCHEMA_VERSION: u32 = 1;
65
66/// How dangerous an invocation is.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum Safety {
70    /// Inspects state; changes nothing on any network or on disk.
71    ReadOnly,
72    /// Writes local files/state (scaffolds, caches, keystores, lock files),
73    /// but never signs or submits anything.
74    LocalWrite,
75    /// Signs and/or submits a transaction, or funds an account. Changes
76    /// ledger state. Always `requires_confirmation`.
77    Mutating,
78}
79
80/// Network prerequisite for the happy path.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum NetworkRequirement {
84    /// Runs fully offline.
85    None,
86    /// Needs a reachable Soroban RPC, conventionally Testnet.
87    Testnet,
88    /// Meaningful against Mainnet (read-only inspection or guarded mutation).
89    Mainnet,
90    /// Any configured network; the endpoint comes from a profile/flags.
91    Both,
92}
93
94/// Exit-code semantics an orchestrator can branch on.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96pub struct ExitSemantics {
97    /// Code for a successful run.
98    pub success: i32,
99    /// Code for a runtime/verdict failure (bad path, RPC error, failed gate).
100    pub runtime_error: i32,
101    /// Code for a usage error (missing/invalid arguments, clap-level).
102    pub usage_error: i32,
103    /// True when a run that produces a *valid* report can still exit
104    /// non-zero because its verdict failed (e.g. artifact mismatch, a
105    /// `critical` health posture, a violated size policy). When false,
106    /// a non-zero exit always means the command itself did not complete.
107    pub verdict_can_fail: bool,
108}
109
110impl ExitSemantics {
111    /// CLI-wide convention: 0 ok, 1 runtime/verdict failure, 2 usage error.
112    const STANDARD: ExitSemantics = ExitSemantics {
113        success: 0,
114        runtime_error: 1,
115        usage_error: 2,
116        verdict_can_fail: false,
117    };
118    /// As [`ExitSemantics::STANDARD`], but a failed verdict also exits 1.
119    const VERDICT_GATED: ExitSemantics = ExitSemantics {
120        verdict_can_fail: true,
121        ..ExitSemantics::STANDARD
122    };
123}
124
125/// Which output modes the command actually implements.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127pub struct OutputFormats {
128    /// Human-readable default output.
129    pub pretty: bool,
130    /// `--format json` produces a parseable document on stdout alone.
131    pub json: bool,
132}
133
134const PRETTY: OutputFormats = OutputFormats {
135    pretty: true,
136    json: false,
137};
138const PRETTY_JSON: OutputFormats = OutputFormats {
139    pretty: true,
140    json: true,
141};
142
143/// How well this entry's claims are backed by observed execution.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum Evidence {
147    /// Executed against local artifacts/fixtures; exit code and output shape
148    /// observed.
149    VerifiedLocal,
150    /// Executed live against a read-only network path; exit code and output
151    /// shape observed.
152    VerifiedTestnet,
153    /// Not executed during the audit that produced this table. `notes`
154    /// explains what is unverified.
155    Unverified,
156}
157
158/// One machine-described CLI capability.
159///
160/// Serialize-only: the borrowed `&'static` fields describe a registry compiled
161/// into the binary, and export (`to_json`) is the consumer contract. Anything
162/// reading an exported document back should deserialize into its own owned
163/// types (see the round-trip test for a worked example).
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165pub struct Capability {
166    /// Stable unique id, `family.action`, snake/dotted lowercase.
167    pub id: &'static str,
168    /// Argument path to the leaf, e.g. `["wasm", "metadata"]`.
169    pub command: &'static [&'static str],
170    /// One-line summary of what it returns.
171    pub summary: &'static str,
172    /// Positional arguments and mandatory flags.
173    pub required_args: &'static [&'static str],
174    /// Optional flags that change or extend the happy path.
175    pub optional_args: &'static [&'static str],
176    pub formats: OutputFormats,
177    pub exit: ExitSemantics,
178    pub network: NetworkRequirement,
179    pub safety: Safety,
180    /// True when an agent must obtain explicit human confirmation before
181    /// invoking. Always true for [`Safety::Mutating`].
182    pub requires_confirmation: bool,
183    pub evidence: Evidence,
184    /// Caveats, prerequisites, or the reason an entry is `Unverified`.
185    pub notes: Option<&'static str>,
186}
187
188impl Capability {
189    /// Read-only, offline, pretty-only, standard exit convention, unverified.
190    /// Entries are written as `Capability { ..Capability::BASE, .. }` so each
191    /// line states only what deviates from this default.
192    const BASE: Capability = Capability {
193        id: "",
194        command: &[],
195        summary: "",
196        required_args: &[],
197        optional_args: &[],
198        formats: PRETTY,
199        exit: ExitSemantics::STANDARD,
200        network: NetworkRequirement::None,
201        safety: Safety::ReadOnly,
202        requires_confirmation: false,
203        evidence: Evidence::Unverified,
204        notes: None,
205    };
206
207    /// Full argv form, for messages and shell previews.
208    pub fn argv(&self) -> String {
209        let mut s = String::from("sdkt");
210        for part in self.command {
211            s.push(' ');
212            s.push_str(part);
213        }
214        s
215    }
216}
217
218/// The registry document: versioned, serializable, agent-consumable.
219#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
220pub struct RegistryDocument<'a> {
221    pub schema_version: u32,
222    pub tool: &'static str,
223    pub capabilities: &'a [Capability],
224}
225
226/// All described capabilities, in a stable (documented-by-lifetime) order.
227pub fn capabilities() -> &'static [Capability] {
228    &CAPABILITIES
229}
230
231/// Build the exportable document.
232pub fn document() -> RegistryDocument<'static> {
233    RegistryDocument {
234        schema_version: SCHEMA_VERSION,
235        tool: "sdkt",
236        capabilities: capabilities(),
237    }
238}
239
240/// Look a capability up by id.
241pub fn find(id: &str) -> Option<&'static Capability> {
242    capabilities().iter().find(|c| c.id == id)
243}
244
245/// Serialize the document to a pretty JSON string for export.
246pub fn to_json() -> Result<String, serde_json::Error> {
247    serde_json::to_string_pretty(&document())
248}
249
250const CAPABILITIES: [Capability; CAP_COUNT] = [
251    // ---- WASM inspection -------------------------------------------------
252    Capability {
253        id: "wasm.inspect",
254        command: &["wasm", "inspect"],
255        summary: "Offline analysis of a local WASM artifact: hash, size, sections, decoded contractmetav0, spec.",
256        required_args: &["<file.wasm>"],
257        optional_args: &["--format"],
258        formats: PRETTY_JSON,
259        evidence: Evidence::VerifiedLocal,
260        ..Capability::BASE
261    },
262    Capability {
263        id: "wasm.metadata",
264        command: &["wasm", "metadata"],
265        summary: "Metadata + parsed ABI + storage/TTL posture for a deployed contract (cached; optional refresh).",
266        required_args: &["--contract <C...>"],
267        optional_args: &["--rpc-url", "--network-passphrase", "--network", "--network-profile", "--refresh", "--format"],
268        formats: PRETTY_JSON,
269        network: NetworkRequirement::Both,
270        evidence: Evidence::VerifiedTestnet,
271        ..Capability::BASE
272    },
273    // ---- Diff / upgrade safety -------------------------------------------
274    Capability {
275        id: "diff",
276        command: &["diff"],
277        summary: "Offline ABI/function/event/type diff between two WASM artifacts, plus signed size deltas.",
278        required_args: &["--old-wasm <f>", "--new-wasm <f>"],
279        optional_args: &["--format", "--upgrade-safety", "--max-size-bytes", "--max-growth-pct"],
280        formats: PRETTY_JSON,
281        exit: ExitSemantics::VERDICT_GATED,
282        evidence: Evidence::VerifiedLocal,
283        notes: Some("Size policy flags are rejected in combination with --upgrade-safety."),
284        ..Capability::BASE
285    },
286    Capability {
287        id: "diff.upgrade_safety",
288        command: &["diff", "--upgrade-safety"],
289        summary: "Breaking/non-breaking upgrade verdict between two artifacts.",
290        required_args: &["--old-wasm <f>", "--new-wasm <f>"],
291        optional_args: &["--format"],
292        formats: PRETTY_JSON,
293        evidence: Evidence::VerifiedLocal,
294        notes: Some(
295            "An incompatible verdict is reported in the JSON (`compatible: false`) but still exits 0 — it is a signal to read, not a gate. Only the opt-in size policy (--max-size-bytes / --max-growth-pct) makes `diff` exit non-zero.",
296        ),
297        ..Capability::BASE
298    },
299    // ---- Static audit -----------------------------------------------------
300    Capability {
301        id: "audit",
302        command: &["audit"],
303        summary: "Heuristic static security analysis of Soroban Rust sources (AUTH/MATH/CEI/MOVE rules).",
304        required_args: &["<paths..>"],
305        optional_args: &["--format", "--disable", "--rules", "--no-plugins", "--list-rules"],
306        formats: PRETTY_JSON,
307        evidence: Evidence::VerifiedLocal,
308        notes: Some(
309            "Findings are advisory signals, never proofs of exploitability; the audit exit code does not encode finding severity (use the GitHub Action's severity-threshold).",
310        ),
311        ..Capability::BASE
312    },
313    Capability {
314        id: "audit.list_rules",
315        command: &["audit", "--list-rules"],
316        summary: "Registered rule ids with severity and description; no source path required.",
317        optional_args: &["--format"],
318        formats: PRETTY_JSON,
319        evidence: Evidence::VerifiedLocal,
320        ..Capability::BASE
321    },
322    // ---- Deployed reality --------------------------------------------------
323    Capability {
324        id: "verify",
325        command: &["verify"],
326        summary: "Compare a local WASM's offline hash against the deployed contract's on-chain hash.",
327        required_args: &["--contract <C...>"],
328        optional_args: &["--rpc-url", "--network-passphrase", "--wasm <f>", "--network", "--network-profile", "--format", "--upgrade-safety"],
329        formats: PRETTY_JSON,
330        exit: ExitSemantics::VERDICT_GATED,
331        network: NetworkRequirement::Both,
332        evidence: Evidence::VerifiedTestnet,
333        notes: Some("Mismatch exits 1; without --wasm the report is OnChainOnly and exits 0."),
334        ..Capability::BASE
335    },
336    Capability {
337        id: "deployment.verify",
338        command: &["deployment-verify"],
339        summary: "Read-only deployment verification: compare a local WASM's offline hash against the deployed contract's executable. Verdicts: MATCH, DRIFT, UNKNOWN, NOT_FOUND.",
340        required_args: &["--contract <C...>"],
341        optional_args: &[
342            "--wasm <f>",
343            "--network",
344            "--rpc-url",
345            "--network-passphrase",
346            "--network-profile",
347            "--format",
348        ],
349        formats: PRETTY_JSON,
350        exit: ExitSemantics::VERDICT_GATED,
351        network: NetworkRequirement::Both,
352        evidence: Evidence::VerifiedTestnet,
353        notes: Some(
354            "Exits 0 only on MATCH; DRIFT, UNKNOWN and NOT_FOUND exit 1 with a valid report. Probes the raw contract-instance ledger entry (no bytecode download); non-Wasm executables (stellar_asset) and unresolvable CAP-85 external refs are UNKNOWN with a reason, never a guessed match. Read-only: never signs, submits or deploys.",
355        ),
356        ..Capability::BASE
357    },
358    Capability {
359        id: "health",
360        command: &["health"],
361        summary: "Read-only contract posture: WASM verification, storage layout, TTL expiry, verdict + reasons.",
362        required_args: &["--contract <C...>"],
363        optional_args: &["--rpc-url", "--network-passphrase", "--wasm <f>", "--network", "--network-profile", "--format"],
364        formats: PRETTY_JSON,
365        exit: ExitSemantics::VERDICT_GATED,
366        network: NetworkRequirement::Both,
367        evidence: Evidence::VerifiedTestnet,
368        notes: Some("A `critical` verdict exits 1 while still printing a full report; `at_risk` and `healthy` exit 0."),
369        ..Capability::BASE
370    },
371    Capability {
372        id: "inspect",
373        command: &["inspect"],
374        summary: "On-chain contract inspection: wasm hash/size, ABI summary, storage and TTL.",
375        required_args: &["<contract-id>"],
376        optional_args: &["--rpc-url", "--network-passphrase", "--network-profile", "--format"],
377        formats: PRETTY_JSON,
378        network: NetworkRequirement::Both,
379        evidence: Evidence::VerifiedTestnet,
380        notes: Some("Protocol 28 `stellar_asset` / CAP-85 external executables have no WASM hash and error explicitly."),
381        ..Capability::BASE
382    },
383    Capability {
384        id: "release_assurance",
385        command: &["release-assurance"],
386        summary: "Aggregate release verdict: artifact, audit, upgrade safety, deployed verification, health, size policy.",
387        required_args: &["--wasm <f>"],
388        optional_args: &[
389            "--rpc-url",
390            "--network-passphrase",
391            "--previous-wasm <f>", "--audit <path>", "--disable <RULE>", "--contract <C>",
392            "--network <n>", "--max-size-bytes <N>", "--max-growth-pct <N>", "--format",
393        ],
394        formats: PRETTY_JSON,
395        exit: ExitSemantics::VERDICT_GATED,
396        network: NetworkRequirement::Both,
397        evidence: Evidence::VerifiedTestnet,
398        notes: Some("Skipped sections never flatten into PASS; --max-growth-pct requires --previous-wasm and is a hard CLI error without it."),
399        ..Capability::BASE
400    },
401    // ---- Storage / events / calls ------------------------------------------
402    Capability {
403        id: "storage.analyze",
404        command: &["storage", "analyze"],
405        summary: "Classify a contract's storage into Instance/Persistent/Temporary with TTL summary.",
406        required_args: &["<contract-id>"],
407        optional_args: &["--rpc-url", "--network-passphrase", "--key-xdr", "--map-key", "--key-arg", "--durability", "--abi-contract", "--format"],
408        formats: PRETTY_JSON,
409        network: NetworkRequirement::Both,
410        evidence: Evidence::VerifiedTestnet,
411        ..Capability::BASE
412    },
413    Capability {
414        id: "events",
415        command: &["events"],
416        summary: "Fetch contract events with ledger range, server-side topic filter, optional ABI decoding.",
417        required_args: &["<contract-id>"],
418        optional_args: &["--rpc-url", "--network-passphrase", "--start-ledger", "--end-ledger", "--topic", "--abi", "--abi-contract", "--format"],
419        formats: PRETTY_JSON,
420        network: NetworkRequirement::Both,
421        evidence: Evidence::VerifiedTestnet,
422        notes: Some("An empty event list is a successful result, not an error."),
423        ..Capability::BASE
424    },
425    Capability {
426        id: "call",
427        command: &["call"],
428        summary: "Read-only contract invocation via simulation; never signs or submits.",
429        required_args: &["<contract-id>", "<function>"],
430        optional_args: &["--rpc-url", "--network-passphrase", "--args", "--args-json", "--abi", "--abi-contract", "--format"],
431        formats: PRETTY_JSON,
432        network: NetworkRequirement::Both,
433        evidence: Evidence::Unverified,
434        notes: Some(
435            "Only the error path was observed this session (simulation errors exit 1 with the host error text). A successful decoded call was not executed; argument shape is function-specific.",
436        ),
437        ..Capability::BASE
438    },
439    Capability {
440        id: "account",
441        command: &["account"],
442        summary: "Account balances and complete signer list (Horizon-enriched).",
443        required_args: &["<address>"],
444        optional_args: &["--rpc-url", "--network-passphrase", "--network-profile", "--format"],
445        formats: PRETTY_JSON,
446        network: NetworkRequirement::Both,
447        evidence: Evidence::VerifiedTestnet,
448        notes: Some("A nonexistent account exits 1 with 'Account not found on network'."),
449        ..Capability::BASE
450    },
451    // ---- Network / diagnostics ---------------------------------------------
452    Capability {
453        id: "network.list",
454        command: &["network", "list"],
455        summary: "Saved network profiles (name, RPC URL, passphrase).",
456        optional_args: &["--format"],
457        formats: PRETTY_JSON,
458        evidence: Evidence::VerifiedLocal,
459        ..Capability::BASE
460    },
461    Capability {
462        id: "network.show",
463        command: &["network", "show"],
464        summary: "One saved profile.",
465        required_args: &["<name>"],
466        optional_args: &["--format"],
467        formats: PRETTY_JSON,
468        evidence: Evidence::VerifiedLocal,
469        ..Capability::BASE
470    },
471    Capability {
472        id: "network.check",
473        command: &["network", "check"],
474        summary: "Profile reachability + node health + protocol version; unreachable endpoint exits non-zero.",
475        required_args: &["<name>"],
476        optional_args: &["--format"],
477        formats: PRETTY_JSON,
478        network: NetworkRequirement::Both,
479        evidence: Evidence::VerifiedLocal,
480        notes: Some(
481            "Resolves a SAVED profile by name and has no --rpc-url; verified against a saved profile on a reachable host.",
482        ),
483        ..Capability::BASE
484    },
485    Capability {
486        id: "network.diagnose",
487        command: &["network", "diagnose"],
488        summary: "Read-only network diagnosis: passphrase identity, protocol agreement across RPC and Horizon, and the ledger resource limits Horizon actually observed.",
489        optional_args: &[
490            "--network",
491            "--rpc-url",
492            "--network-passphrase",
493            "--network-profile",
494            "--format",
495        ],
496        formats: PRETTY_JSON,
497        exit: ExitSemantics::VERDICT_GATED,
498        network: NetworkRequirement::Both,
499        evidence: Evidence::VerifiedTestnet,
500        notes: Some(
501            "Exit 0 only when the diagnosis status is ok; identity mismatch, protocol inconsistency or an unreachable endpoint exit 1 with a valid report. Resource limits come solely from Horizon's ledger resource and are reported unavailable (never hardcoded) when Horizon does not answer. Read-only: getHealth/getNetwork/getLatestLedger plus Horizon GETs only.",
502        ),
503        ..Capability::BASE
504    },
505    Capability {
506        id: "network.add",
507        command: &["network", "add"],
508        summary: "Save a named RPC endpoint + passphrase profile.",
509        required_args: &["<name>", "--rpc-url <url>", "--passphrase <phrase>"],
510        optional_args: &["--friendbot", "--description", "--format"],
511        formats: PRETTY_JSON,
512        safety: Safety::LocalWrite,
513        evidence: Evidence::VerifiedLocal,
514        notes: Some("Writes user config under the sdkt network dir; reversible with network remove."),
515        ..Capability::BASE
516    },
517    Capability {
518        id: "network.remove",
519        command: &["network", "remove"],
520        summary: "Delete a saved network profile.",
521        required_args: &["<name>"],
522        optional_args: &["--format"],
523        formats: PRETTY_JSON,
524        safety: Safety::LocalWrite,
525        evidence: Evidence::VerifiedLocal,
526        ..Capability::BASE
527    },
528    Capability {
529        id: "doctor",
530        command: &["doctor"],
531        summary: "Environment/toolchain/project diagnostics with a healthy flag.",
532        optional_args: &["--format", "--json"],
533        formats: PRETTY_JSON,
534        evidence: Evidence::VerifiedLocal,
535        ..Capability::BASE
536    },
537    // ---- Keystore ------------------------------------------------------------
538    Capability {
539        id: "identity.list",
540        command: &["identity", "list"],
541        summary: "Local ED25519 identities (names + public keys; no secret material).",
542        safety: Safety::ReadOnly,
543        evidence: Evidence::VerifiedLocal,
544        notes: Some("No --format json (known limitation; output is human text only)."),
545        ..Capability::BASE
546    },
547    Capability {
548        id: "identity.generate",
549        command: &["identity", "generate"],
550        summary: "Create a new keypair in the local keystore.",
551        required_args: &["<name>"],
552        optional_args: &[],
553        safety: Safety::LocalWrite,
554        evidence: Evidence::Unverified,
555        notes: Some("Not executed by the audit; keystore write, no ledger interaction. Secret material must never be surfaced to an agent."),
556        ..Capability::BASE
557    },
558    Capability {
559        id: "identity.import",
560        command: &["identity", "import"],
561        summary: "Store an existing secret key in the local keystore.",
562        required_args: &["<name>", "<secret-key>"],
563        safety: Safety::LocalWrite,
564        evidence: Evidence::Unverified,
565        notes: Some("Not executed by the audit. Handles secret input; an agent must never receive the key material in either direction."),
566        ..Capability::BASE
567    },
568    Capability {
569        id: "identity.delete",
570        command: &["identity", "delete"],
571        summary: "Remove an identity from the local keystore.",
572        required_args: &["<name>"],
573        safety: Safety::LocalWrite,
574        evidence: Evidence::Unverified,
575        notes: Some("Not executed by the audit; irreversible local key destruction — confirmation required for the operator."),
576        requires_confirmation: true,
577        ..Capability::BASE
578    },
579    Capability {
580        id: "identity.default",
581        command: &["identity", "default"],
582        summary: "Show or set the default identity.",
583        required_args: &["[name]"],
584        safety: Safety::LocalWrite,
585        evidence: Evidence::Unverified,
586        notes: Some("Not executed by the audit; read when called without a name, write when given one."),
587        ..Capability::BASE
588    },
589    // ---- Build / scaffold / package / lock -----------------------------------
590    Capability {
591        id: "build",
592        command: &["build"],
593        summary: "Compile workspace contracts to WASM artifacts; JSON reports success + artifact list.",
594        optional_args: &["--format"],
595        formats: PRETTY_JSON,
596        safety: Safety::LocalWrite,
597        evidence: Evidence::VerifiedLocal,
598        notes: Some("Requires the wasm32v1-none target; the advisory sdkt.lock report goes to stderr so stdout stays pure JSON. Protocol 28 projects route through `stellar contract build`."),
599        ..Capability::BASE
600    },
601    Capability {
602        id: "init",
603        command: &["init"],
604        summary: "Scaffold a new Soroban contract project.",
605        required_args: &["<name>"],
606        optional_args: &["--minimal", "--force", "--format"],
607        formats: PRETTY_JSON,
608        safety: Safety::LocalWrite,
609        evidence: Evidence::VerifiedLocal,
610        ..Capability::BASE
611    },
612    Capability {
613        id: "generate.client",
614        command: &["generate", "client"],
615        summary: "Generate typed client Rust from a contract WASM spec.",
616        required_args: &["<file.wasm>"],
617        optional_args: &["--output <PATH>", "--skip-unsupported"],
618        safety: Safety::LocalWrite,
619        evidence: Evidence::VerifiedLocal,
620        notes: Some("No --format json; emits Rust source (stdout or --output)."),
621        ..Capability::BASE
622    },
623    Capability {
624        id: "package.validate",
625        command: &["package", "validate"],
626        summary: "Offline validation of the local package manifest + dependencies.",
627        optional_args: &["--format"],
628        formats: PRETTY_JSON,
629        evidence: Evidence::VerifiedLocal,
630        notes: Some("An invalid manifest is a valid JSON report ({valid:false,error}) and exits non-zero."),
631        ..Capability::BASE
632    },
633    Capability {
634        id: "package.update",
635        command: &["package", "update"],
636        summary: "Resolve/update git dependencies against semver constraints.",
637        optional_args: &["--check", "--dry-run", "--format"],
638        formats: PRETTY_JSON,
639        safety: Safety::LocalWrite,
640        network: NetworkRequirement::Both,
641        evidence: Evidence::Unverified,
642        notes: Some("Not executed by the audit; --dry-run/--check are the safe modes an agent should start with."),
643        ..Capability::BASE
644    },
645    Capability {
646        id: "package.publish",
647        command: &["package", "publish"],
648        summary: "Publish-readiness check / publish of the package.",
649        optional_args: &["--dry-run"],
650        safety: Safety::LocalWrite,
651        evidence: Evidence::Unverified,
652        notes: Some("Not executed by the audit; --dry-run is the non-mutating mode. Actual publication is external and must stay human-gated."),
653        requires_confirmation: true,
654        ..Capability::BASE
655    },
656    Capability {
657        id: "lock.verify",
658        command: &["lock", "verify"],
659        summary: "Verify sdkt.lock against on-disk artifacts and resolved dependencies.",
660        optional_args: &["--format"],
661        formats: PRETTY_JSON,
662        evidence: Evidence::VerifiedLocal,
663        notes: Some("A missing/invalid lock exits 1 with an error line."),
664        ..Capability::BASE
665    },
666    Capability {
667        id: "lock.generate",
668        command: &["lock", "generate"],
669        summary: "Write sdkt.lock from current build artifacts (build first).",
670        optional_args: &["--format"],
671        formats: PRETTY_JSON,
672        safety: Safety::LocalWrite,
673        evidence: Evidence::VerifiedLocal,
674        ..Capability::BASE
675    },
676    // ---- Plugins ---------------------------------------------------------------
677    Capability {
678        id: "plugin.list",
679        command: &["plugin", "list"],
680        summary: "Installed audit plugins in the local store.",
681        optional_args: &["--format"],
682        formats: PRETTY_JSON,
683        evidence: Evidence::VerifiedLocal,
684        ..Capability::BASE
685    },
686    Capability {
687        id: "plugin.doctor",
688        command: &["plugin", "doctor"],
689        summary: "Staged plugin self-check (metadata, integrity, ABI, dry-run).",
690        required_args: &["[target]"],
691        optional_args: &["--all", "--format"],
692        formats: PRETTY_JSON,
693        exit: ExitSemantics {
694            runtime_error: 1,
695            ..ExitSemantics::STANDARD
696        },
697        evidence: Evidence::VerifiedLocal,
698        notes: Some("Exit code encodes the failed stage (1..=6), not just pass/fail."),
699        ..Capability::BASE
700    },
701    Capability {
702        id: "plugin.verify_bundle",
703        command: &["plugin", "verify-bundle"],
704        summary: "Verify a .sdktplugin bundle's digests and signature without installing.",
705        required_args: &["<bundle>"],
706        optional_args: &["--public-key <file>", "--format"],
707        formats: PRETTY_JSON,
708        evidence: Evidence::VerifiedLocal,
709        ..Capability::BASE
710    },
711    Capability {
712        id: "plugin.install",
713        command: &["plugin", "install"],
714        summary: "Install a plugin artifact or bundle into the local store.",
715        required_args: &["<path>"],
716        optional_args: &["--id", "--force", "--public-key <file>", "--format"],
717        formats: PRETTY_JSON,
718        safety: Safety::LocalWrite,
719        evidence: Evidence::Unverified,
720        notes: Some("Not executed by the audit; third-party code entering the plugin store — treat as supply-chain-sensitive and require confirmation."),
721        requires_confirmation: true,
722        ..Capability::BASE
723    },
724    // ---- Fee / tx --------------------------------------------------------------
725    Capability {
726        id: "fee.estimate",
727        command: &["fee", "estimate"],
728        summary: "Fee estimate from supplied base fees or live RPC ledger-fee statistics.",
729        optional_args: &["--rpc-url", "--network-passphrase", "--network", "--base-fees", "--rpc", "--network-profile", "--format"],
730        formats: PRETTY_JSON,
731        network: NetworkRequirement::Both,
732        evidence: Evidence::VerifiedLocal,
733        notes: Some("--base-fees path is offline; --rpc fetches network statistics (read-only)."),
734        ..Capability::BASE
735    },
736    Capability {
737        id: "tx.decode",
738        command: &["tx", "decode"],
739        summary: "Human/JSON breakdown of a transaction envelope: ops, footprint, resources, signatures.",
740        required_args: &["<xdr|file>"],
741        optional_args: &["--format"],
742        formats: PRETTY_JSON,
743        evidence: Evidence::VerifiedLocal,
744        notes: Some("Fully offline. Malformed base64 or trailing bytes exit non-zero; the valid-envelope breakdown was not exercised this session."),
745        ..Capability::BASE
746    },
747    Capability {
748        id: "tx.validate",
749        command: &["tx", "validate"],
750        summary: "Validate an envelope offline (structure, missing file, input resolution).",
751        required_args: &["<xdr|file>"],
752        optional_args: &["--format"],
753        formats: PRETTY_JSON,
754        evidence: Evidence::Unverified,
755        notes: Some("Only the invalid-input path was observed (exit non-zero with a shared error message); a passing validation was not executed."),
756        ..Capability::BASE
757    },
758    Capability {
759        id: "tx.simulate",
760        command: &["tx", "simulate"],
761        summary: "Simulate an envelope without submitting: resources, auth entries, diagnostics, stateChanges.",
762        required_args: &["--envelope <xdr|file>"],
763        optional_args: &["--rpc-url", "--network-passphrase", "--abi", "--abi-contract", "--network-profile", "--format"],
764        formats: PRETTY_JSON,
765        network: NetworkRequirement::Both,
766        evidence: Evidence::Unverified,
767        notes: Some("Not executed by this audit; read-only against the network, never signs or submits."),
768        ..Capability::BASE
769    },
770    Capability {
771        id: "tx.build",
772        command: &["tx", "build"],
773        summary: "Build an invocation envelope (optionally simulating to adopt fee/footprint).",
774        required_args: &["--contract <C>", "--function <fn>"],
775        optional_args: &["--rpc-url", "--network-passphrase", "--arg", "--source", "--sequence", "--fee", "--memo-text", "--network-profile", "--format"],
776        formats: PRETTY_JSON,
777        network: NetworkRequirement::Both,
778        safety: Safety::ReadOnly,
779        evidence: Evidence::Unverified,
780        notes: Some("Produces bytes only — it does not sign or submit — but the output is submission-ready, so downstream intent matters."),
781        ..Capability::BASE
782    },
783    Capability {
784        id: "project.status",
785        command: &["project", "status"],
786        summary: "Multi-contract project resolution + deployment status.",
787        optional_args: &["--rpc-url", "--network-passphrase", "--format", "--network-profile"],
788        formats: PRETTY_JSON,
789        network: NetworkRequirement::Both,
790        evidence: Evidence::Unverified,
791        notes: Some("Not executed by this audit; read-only."),
792        ..Capability::BASE
793    },
794    // ---- Mutating capabilities: never agent-executable without confirmation ---
795    Capability {
796        id: "deploy",
797        command: &["deploy"],
798        summary: "Upload WASM + instantiate a contract on a network (signs and submits).",
799        required_args: &["--wasm <f>"],
800        optional_args: &[
801            "--arg", "--salt", "--wasm-hash", "--identity", "--network-profile",
802            "--deny-breaking", "--format",
803        ],
804        formats: PRETTY_JSON,
805        safety: Safety::Mutating,
806        requires_confirmation: true,
807        network: NetworkRequirement::Both,
808        evidence: Evidence::Unverified,
809        notes: Some(
810            "NEVER agent-executable without explicit human confirmation: spends fees and writes ledger state. Mainnet requires an explicitly selected network (safety guard). Not exercised by the audit — deploy/upload is out of read-only bounds.",
811        ),
812        ..Capability::BASE
813    },
814    Capability {
815        id: "invoke",
816        command: &["invoke"],
817        summary: "Signed, submitted state-changing invocation (sequence → simulate → sign → submit → poll).",
818        required_args: &["<contract-id>", "<function>"],
819        optional_args: &["--args", "--identity", "--build-only", "--network-profile", "--format"],
820        formats: PRETTY_JSON,
821        safety: Safety::Mutating,
822        requires_confirmation: true,
823        network: NetworkRequirement::Both,
824        evidence: Evidence::Unverified,
825        notes: Some("NEVER agent-executable without explicit human confirmation. --build-only stops before submission and is the only acceptable agent path."),
826        ..Capability::BASE
827    },
828    Capability {
829        id: "tx.sign",
830        command: &["tx", "sign"],
831        summary: "Sign an envelope with a local ED25519 identity.",
832        required_args: &["<envelope>", "--identity <name>"],
833        optional_args: &["--network-profile", "--format"],
834        formats: PRETTY_JSON,
835        safety: Safety::Mutating,
836        requires_confirmation: true,
837        evidence: Evidence::Unverified,
838        notes: Some("Key-bearing operation. An agent must never see secret material; signing authority requires explicit human confirmation."),
839        ..Capability::BASE
840    },
841    Capability {
842        id: "tx.submit",
843        command: &["tx", "submit"],
844        summary: "Submit a signed envelope to the network.",
845        required_args: &["<envelope>"],
846        optional_args: &["--network-profile", "--format"],
847        formats: PRETTY_JSON,
848        safety: Safety::Mutating,
849        requires_confirmation: true,
850        network: NetworkRequirement::Both,
851        evidence: Evidence::Unverified,
852        notes: Some("Broadcasts irreversible ledger state; mainnet guarded by explicit-network requirement."),
853        ..Capability::BASE
854    },
855    Capability {
856        id: "storage.extend",
857        command: &["storage", "extend"],
858        summary: "Extend TTL of a contract footprint via ExtendFootprintTtl.",
859        required_args: &["<contract-id>"],
860        optional_args: &["--ledgers", "--identity", "--network-profile", "--format"],
861        formats: PRETTY_JSON,
862        safety: Safety::Mutating,
863        requires_confirmation: true,
864        network: NetworkRequirement::Both,
865        evidence: Evidence::Unverified,
866        notes: Some("Submits a transaction and spends fees. It has no dry-run mode, so an agent must never call it unattended."),
867        ..Capability::BASE
868    },
869    Capability {
870        id: "storage.restore",
871        command: &["storage", "restore"],
872        summary: "Restore archived storage by adopting a simulation's restorePreamble.",
873        required_args: &["--envelope <xdr>"],
874        optional_args: &["--dry-run", "--identity", "--network-profile", "--format"],
875        formats: PRETTY_JSON,
876        safety: Safety::Mutating,
877        requires_confirmation: true,
878        network: NetworkRequirement::Both,
879        evidence: Evidence::Unverified,
880        notes: Some("Submits a RestoreFootprint transaction. --dry-run lists keys/fee without submitting."),
881        ..Capability::BASE
882    },
883    Capability {
884        id: "project.deploy",
885        command: &["project", "deploy"],
886        summary: "Deploy a whole multi-contract project in resolved dependency order.",
887        optional_args: &["--identity", "--network-profile", "--format"],
888        formats: PRETTY_JSON,
889        safety: Safety::Mutating,
890        requires_confirmation: true,
891        network: NetworkRequirement::Both,
892        evidence: Evidence::Unverified,
893        notes: Some("Fan-out of deploy transactions; highest blast radius in the toolkit."),
894        ..Capability::BASE
895    },
896    Capability {
897        id: "identity.fund",
898        command: &["identity", "fund"],
899        summary: "Fund an account via Testnet Friendbot.",
900        required_args: &["<name>"],
901        optional_args: &["--format"],
902        formats: PRETTY_JSON,
903        safety: Safety::Mutating,
904        requires_confirmation: true,
905        network: NetworkRequirement::Testnet,
906        evidence: Evidence::Unverified,
907        notes: Some("Submits a funding transaction; testnet only."),
908        ..Capability::BASE
909    },
910];
911
912/// Number of described capabilities; kept as a const so the array length is
913/// checked by the compiler against every entry.
914const CAP_COUNT: usize = 53;
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919    use std::collections::HashSet;
920
921    #[test]
922    fn capability_ids_are_unique() {
923        let mut seen = HashSet::new();
924        let dupes: Vec<_> = capabilities()
925            .iter()
926            .filter(|c| !seen.insert(c.id))
927            .map(|c| c.id)
928            .collect();
929        assert!(dupes.is_empty(), "duplicate capability ids: {dupes:?}");
930    }
931
932    #[test]
933    fn required_fields_are_present() {
934        for c in capabilities() {
935            assert!(!c.id.is_empty(), "empty id");
936            assert!(
937                c.id.is_ascii() && !c.id.contains(char::is_whitespace),
938                "id must be ascii and space-free: {}",
939                c.id
940            );
941            assert!(!c.summary.is_empty(), "{}: empty summary", c.id);
942            assert!(
943                !c.command.is_empty(),
944                "{}: command path must be non-empty",
945                c.id
946            );
947            // The argv must start with the leaf command path it claims.
948            assert!(
949                c.argv().starts_with("sdkt ") && c.argv().contains(c.command[c.command.len() - 1]),
950                "{}: argv {} does not contain its leaf command",
951                c.id,
952                c.argv()
953            );
954        }
955    }
956
957    #[test]
958    fn mutating_capabilities_always_require_confirmation() {
959        for c in capabilities() {
960            if c.safety == Safety::Mutating {
961                assert!(
962                    c.requires_confirmation,
963                    "{}: mutating capability must require confirmation",
964                    c.id
965                );
966            }
967        }
968    }
969
970    #[test]
971    fn read_only_capabilities_never_require_confirmation() {
972        for c in capabilities() {
973            if c.safety == Safety::ReadOnly {
974                assert!(
975                    !c.requires_confirmation,
976                    "{}: read-only capability must not be flagged for confirmation",
977                    c.id
978                );
979            }
980        }
981    }
982
983    #[test]
984    fn mutating_capabilities_never_claim_verified_execution() {
985        // The audit is read-only; nothing that signs/submits was executed.
986        for c in capabilities() {
987            if c.safety == Safety::Mutating {
988                assert_eq!(
989                    c.evidence,
990                    Evidence::Unverified,
991                    "{}: mutating capability cannot be marked verified by a read-only audit",
992                    c.id
993                );
994            }
995        }
996    }
997
998    #[test]
999    fn unverified_entries_explain_themselves() {
1000        for c in capabilities() {
1001            if c.evidence == Evidence::Unverified {
1002                assert!(
1003                    c.notes.is_some(),
1004                    "{}: Unverified entries must say what is missing",
1005                    c.id
1006                );
1007            }
1008        }
1009    }
1010
1011    #[test]
1012    fn exit_convention_is_consistent() {
1013        for c in capabilities() {
1014            assert_eq!(c.exit.success, 0, "{}: success must be 0", c.id);
1015            assert_eq!(c.exit.runtime_error, 1, "{}: runtime error must be 1", c.id);
1016            assert_eq!(c.exit.usage_error, 2, "{}: usage error must be 2", c.id);
1017        }
1018    }
1019
1020    #[test]
1021    fn json_format_flags_match_cli() {
1022        // Pinned from observed `--help` + executed `--format json` runs.
1023        const JSON: &[&str] = &[
1024            "wasm.inspect",
1025            "wasm.metadata",
1026            "diff",
1027            "diff.upgrade_safety",
1028            "audit",
1029            "audit.list_rules",
1030            "verify",
1031            "health",
1032            "inspect",
1033            "release_assurance",
1034            "deployment.verify",
1035            "network.diagnose",
1036            "storage.analyze",
1037            "events",
1038            "call",
1039            "account",
1040            "network.list",
1041            "network.show",
1042            "network.check",
1043            "network.add",
1044            "network.remove",
1045            "doctor",
1046            "build",
1047            "init",
1048            "package.validate",
1049            "package.update",
1050            "lock.verify",
1051            "lock.generate",
1052            "plugin.list",
1053            "plugin.doctor",
1054            "plugin.verify_bundle",
1055            "plugin.install",
1056            "fee.estimate",
1057            "tx.decode",
1058            "tx.validate",
1059            "tx.simulate",
1060            "tx.build",
1061            "project.status",
1062            "deploy",
1063            "invoke",
1064            "tx.sign",
1065            "tx.submit",
1066            "storage.extend",
1067            "storage.restore",
1068            "project.deploy",
1069            "identity.fund",
1070        ];
1071        const NO_JSON: &[&str] = &["identity.list", "generate.client", "package.publish"];
1072        for id in JSON {
1073            let c = find(id).unwrap_or_else(|| panic!("missing {id}"));
1074            assert!(
1075                c.formats.json,
1076                "{id}: CLI accepts --format json but registry says no"
1077            );
1078        }
1079        for id in NO_JSON {
1080            let c = find(id).unwrap_or_else(|| panic!("missing {id}"));
1081            assert!(
1082                !c.formats.json,
1083                "{id}: registry claims JSON the CLI does not provide"
1084            );
1085            assert!(
1086                c.notes.is_some(),
1087                "{id}: missing-JSON limitation must be noted"
1088            );
1089        }
1090    }
1091
1092    #[test]
1093    fn verdict_gated_commands_are_marked() {
1094        // Commands that can exit 1 while still emitting a valid report.
1095        for id in [
1096            "health",
1097            "verify",
1098            "release_assurance",
1099            "diff",
1100            "deployment.verify",
1101            "network.diagnose",
1102        ] {
1103            let c = find(id).expect("pinned verdict command");
1104            assert!(
1105                c.exit.verdict_can_fail,
1106                "{id}: verdict-gated exit must be flagged"
1107            );
1108        }
1109        // `diff.upgrade_safety` reports incompatibility in JSON but exits 0:
1110        // verified by running it, so it must NOT claim a verdict gate.
1111        for id in [
1112            "wasm.inspect",
1113            "audit",
1114            "inspect",
1115            "events",
1116            "account",
1117            "diff.upgrade_safety",
1118        ] {
1119            let c = find(id).expect("pinned command");
1120            assert!(
1121                !c.exit.verdict_can_fail,
1122                "{id}: not a verdict gate; a non-zero exit means the command did not complete"
1123            );
1124        }
1125    }
1126
1127    #[test]
1128    fn document_serializes_to_a_machine_readable_manifest() {
1129        let json = to_json().expect("registry must serialize");
1130        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
1131        assert_eq!(v["schema_version"], SCHEMA_VERSION);
1132        assert_eq!(v["tool"], "sdkt");
1133        let list = v["capabilities"].as_array().expect("array");
1134        assert_eq!(list.len(), CAP_COUNT);
1135        // Every entry carries the fields an agent needs to plan a call.
1136        for entry in list {
1137            for key in [
1138                "id",
1139                "command",
1140                "summary",
1141                "required_args",
1142                "optional_args",
1143                "formats",
1144                "exit",
1145                "network",
1146                "safety",
1147                "requires_confirmation",
1148                "evidence",
1149            ] {
1150                assert!(entry.get(key).is_some(), "missing {key} in {entry}");
1151            }
1152        }
1153    }
1154
1155    #[test]
1156    fn document_round_trips() {
1157        let json = to_json().unwrap();
1158        let back: RegistryDocumentOwned =
1159            serde_json::from_str(&json).expect("registry document must deserialize");
1160        assert_eq!(back.capabilities.len(), CAP_COUNT);
1161        assert!(back
1162            .capabilities
1163            .iter()
1164            .any(|c| c.id == "release_assurance" && c.safety == Safety::ReadOnly));
1165    }
1166
1167    #[test]
1168    fn mutation_surface_is_not_agent_executable_by_default() {
1169        let mutating: Vec<_> = capabilities()
1170            .iter()
1171            .filter(|c| c.safety == Safety::Mutating)
1172            .collect();
1173        // deploy, invoke, tx.sign, tx.submit, storage.extend, storage.restore,
1174        // project.deploy, identity.fund
1175        assert_eq!(
1176            mutating.len(),
1177            8,
1178            "unexpected mutating surface: {mutating:?}"
1179        );
1180        assert!(mutating.iter().all(|c| c.requires_confirmation));
1181    }
1182
1183    #[test]
1184    fn release_assurance_is_the_default_agent_entry_point() {
1185        let ra = find("release_assurance").unwrap();
1186        assert_eq!(ra.safety, Safety::ReadOnly);
1187        assert!(!ra.requires_confirmation);
1188        assert!(ra.exit.verdict_can_fail);
1189        assert_eq!(ra.network, NetworkRequirement::Both);
1190    }
1191
1192    /// Owned mirror of [`RegistryDocument`] for round-trip tests.
1193    #[derive(Deserialize)]
1194    struct RegistryDocumentOwned {
1195        #[allow(dead_code)]
1196        schema_version: u32,
1197        #[allow(dead_code)]
1198        capabilities: Vec<CapabilityOwned>,
1199    }
1200
1201    #[derive(Deserialize, PartialEq, Eq, Debug)]
1202    struct CapabilityOwned {
1203        id: String,
1204        summary: String,
1205        safety: Safety,
1206        requires_confirmation: bool,
1207    }
1208}